Desktop Notifications for Claude Code
Get Windows desktop notifications whenever Claude Code finishes a task or needs your input — works in both standalone terminals and VSCode's integrated terminal.
Disclaimer
This is an advanced customization using Claude Code's hooks feature. It requires creating and running a PowerShell script on your machine, which may have security implications if you don't understand the code. Always review and understand any code you run on your system. This code is provided under the standard MIT license.
What This Provides
When running long tasks with Claude Code, it's easy to switch context and miss when Claude is done or waiting on you. This setup adds two types of toast notifications:
- Task finished — fires when Claude completes a response, showing the project name and first line of what it did
- Needs input — fires when Claude is waiting for your approval or answer, showing Claude's prompt message
Both notifications display the current project name so you always know which Claude session is calling for attention.
How Claude Code Hooks Work
Claude Code supports hooks — shell commands that run automatically in response to lifecycle events. They are configured in ~/.claude/settings.json and apply globally across all projects on the machine.
The two hooks used here:
| Hook | When it fires | Stdin JSON |
|---|---|---|
Stop | Claude finishes a response | transcript array of all messages |
Notification | Claude needs user input | message string with the prompt |
Each hook receives a JSON payload via stdin that the script can parse to build a meaningful notification message.
Step 1 — Create the Notification Script
Create the file C:\Users\<you>\.claude\notify.ps1:
$raw = [System.Console]::In.ReadToEnd()
$project = Split-Path -Leaf (Get-Location)
$title = "Claude Code"
$summary = "Finished in $project"
try {
$data = $raw | ConvertFrom-Json
# Notification hook provides a direct message field
if ($data.message) {
$title = "Claude Code needs input [$project]"
$msg = $data.message.Trim()
if ($msg.Length -gt 120) { $msg = $msg.Substring(0, 117) + "..." }
$summary = $msg
}
else {
# Stop hook — extract first line of last assistant message
$transcript = $data.transcript
$lastAssistant = ($transcript | Where-Object { $_.role -eq "assistant" } | Select-Object -Last 1)
if ($lastAssistant) {
$content = $lastAssistant.content
$text = $null
if ($content -is [string]) {
$text = $content
} else {
foreach ($block in $content) {
if ($block.type -eq "text" -and $block.text) {
$text = $block.text
break
}
}
}
if ($text) {
$firstLine = ($text -split "`n" | Where-Object { $_.Trim() -ne "" } | Select-Object -First 1).Trim()
if ($firstLine.Length -gt 120) { $firstLine = $firstLine.Substring(0, 117) + "..." }
if ($firstLine) { $summary = "[$project] $firstLine" }
}
}
}
} catch {}
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$notify = New-Object System.Windows.Forms.NotifyIcon
$notify.Icon = [System.Drawing.SystemIcons]::Information
$notify.Visible = $true
$notify.BalloonTipTitle = $title
$notify.BalloonTipText = $summary
$notify.BalloonTipIcon = "Info"
$notify.ShowBalloonTip(8000)
Start-Sleep -Seconds 9
$notify.Dispose()
Key Parts of the Script
Reading stdin
$raw = [System.Console]::In.ReadToEnd()
Claude Code pipes the hook's JSON payload to the script via stdin. ReadToEnd() captures the full payload before parsing.
Getting the project name
$project = Split-Path -Leaf (Get-Location)
Hooks run in the directory where Claude Code was launched, so Get-Location gives us the project folder and Split-Path -Leaf extracts just the name.
Distinguishing hook type
if ($data.message) { ... } else { ... }
The Notification hook JSON always contains a message field. The Stop hook does not — it provides a transcript array instead. Checking for message lets a single script handle both hooks cleanly.
Extracting the summary from the transcript
$lastAssistant = ($transcript | Where-Object { $_.role -eq "assistant" } | Select-Object -Last 1)
The transcript is an ordered array of { role, content } objects. Filtering for role -eq "assistant" and taking the last one gives us Claude's most recent response. The content may be a plain string or an array of typed blocks (text, tool_use, etc.), so both cases are handled.
Showing the toast
$notify = New-Object System.Windows.Forms.NotifyIcon
$notify.ShowBalloonTip(8000)
Start-Sleep -Seconds 9
$notify.Dispose()
System.Windows.Forms.NotifyIcon creates a temporary system-tray icon and fires a balloon/toast notification. The script sleeps for 9 seconds (slightly longer than the 8-second display time) to keep the process alive while the notification is visible, then cleans up.
Step 2 — Register the Hooks
Open C:\Users\<you>\.claude\settings.json and add the hooks block:
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\\Users\\<you>\\.claude\\notify.ps1\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\\Users\\<you>\\.claude\\notify.ps1\""
}
]
}
]
}
}
Replace <you> with your Windows username. Any existing settings (permissions, model, etc.) sit alongside the hooks key at the top level.
Why -ExecutionPolicy Bypass?
Windows may have its execution policy set to restrict running .ps1 files. The -ExecutionPolicy Bypass flag overrides this for this one invocation only — it does not change the system-wide policy.
Why two separate hook entries for the same script?
Notification and Stop are distinct lifecycle events with different JSON shapes. Registering the same script for both keeps the configuration DRY while the script itself handles the difference at runtime.
Step 3 — Test It
Run this from any terminal to fire a test notification:
echo '{"transcript":[{"role":"assistant","content":"Test notification is working!"}]}' | \
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$HOME/.claude/notify.ps1"
You should see a balloon notification in the bottom-right corner near the system tray titled Claude Code with the text [<folder>] Test notification is working!.
Troubleshooting
No notification appears
- Check that Windows notifications are not blocked for PowerShell under Settings → System → Notifications
- Ensure Focus Assist / Do Not Disturb is off
- Confirm the path in
settings.jsonmatches the actual location ofnotify.ps1
"Running scripts is disabled" error
- Make sure
-ExecutionPolicy Bypassis present in the command insidesettings.json
Summary always shows "Finished in <project>"
- The transcript content block format may differ. The fallback message is intentional — it still tells you which project finished.
