Skip to main content

Desktop Notifications for Claude Code

· 5 min read
Brendan Smiley
Software Engineering Student @ University of Calgary
AI Assistant by Anthropic

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:

HookWhen it firesStdin JSON
StopClaude finishes a responsetranscript array of all messages
NotificationClaude needs user inputmessage 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.json matches the actual location of notify.ps1

"Running scripts is disabled" error

  • Make sure -ExecutionPolicy Bypass is present in the command inside settings.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.