Artigos12 min de leitura

Notificações no Claude Code: hooks Notification e Stop no WSL com VS Code

Por Publicado em
Infográfico de capa: linha do tempo de um turno do agente, com o agente parando às 14h02 para pedir aprovação e o humano só percebendo às 14h21, marcando dezenove minutos perdidos; coluna à esquerda com preferredNotifChannel, escopo de usuário, hooks Notification e Stop, FlashWindowEx e kill-switch; ao centro o fluxo do hook indo do JSON no stdin para o roteador em bash e daí para o piscar da barra de tarefas do Windows

O problema

Você pede uma tarefa longa para o agente, troca de janela para fazer outra coisa e volta vinte minutos

depois. O agente trabalhou por um minuto, parou para pedir aprovação de um comando e ficou os outros

dezenove esperando você olhar para a tela.

Escrevi sobre como eu opero agentes no dia a dia, e o ponto central

lá é que o objetivo é deixar de ser a peça que dá o próximo passo. Só que existe um ponto do ciclo em que

o humano é obrigatório: aprovar uma operação destrutiva, responder uma pergunta, decidir entre dois

caminhos. Esse ponto não dá para automatizar, mas dá para encurtar. Se o agente para às 14h02 e você

descobre às 14h21, dezenove minutos foram embora por um motivo bobo: ninguém te avisou.

Este artigo é o passo a passo de como resolver isso no ambiente que eu uso, que é provavelmente o mais

comum entre quem trabalha com plataforma no Windows: Claude Code via CLI, rodando no WSL, dentro do

terminal integrado do VS Code.

O caminho nativo: preferredNotifChannel

O Claude Code tem uma configuração própria de notificação. Ela vive no settings.json e aceita estes

valores:

ValorO que faz
autoPadrão. Detecta o terminal e usa a notificação nativa dele
terminal_bellEmite o caractere de bell (\a)
iterm2 / iterm2_with_bellNotificação nativa do iTerm2 (macOS)
kitty / ghosttyNotificação nativa desses terminais
notifications_disabledDesliga tudo

O detalhe que importa: auto não entrega nada no terminal integrado do VS Code. A detecção procura

iTerm2, Kitty ou Ghostty. Não achando nenhum, ela não tem para onde mandar e silenciosamente não faz nada.

Como o padrão é auto, a conclusão natural de quem roda no VS Code é que "o Claude Code não notifica",

quando na verdade ele está notificando para um canal que não existe ali.

O que funciona nesse terminal é o bell:

json
// ~/.claude/settings.json
{
  "preferredNotifChannel": "terminal_bell"
}

E, do lado do VS Code:

json
// settings.json do VS Code
{
  "terminal.integrated.enableBell": true
}

Com os dois ligados, a aba do terminal ganha um ícone de sino quando o agente pede atenção. É barato e vale

a pena ligar. Mas resolve pouco: só te ajuda se você já estiver com o VS Code na tela. Se você trocou de

janela — que é exatamente o caso do problema — um marcador dentro da janela que você não está vendo não

serve de nada.

Para isso a gente precisa de hooks.

Infográfico com os cinco valores de preferredNotifChannel em cartões: auto marcado como padrão, terminal_bell, iterm2 e iterm2_with_bell, kitty e ghostty, e notifications_disabled. O cartão auto está destacado em violeta com a anotação de que a detecção procura iTerm2, Kitty e Ghostty e, não achando nenhum, não manda nada. Abaixo, duas caixas de configuração lado a lado, preferredNotifChannel terminal_bell no settings.json do Claude Code e terminal.integrated.enableBell true no settings.json do VS Code, ligadas a um ícone de sino na aba do terminal

A pegadinha do escopo: usuário ou projeto?

Antes do código, o erro que faz um hook correto nunca rodar.

O Claude Code tem dois lugares para declarar hooks: o settings.json do projeto

(.claude/settings.json no repositório) e o do usuário (~/.claude/settings.json). A diferença é

sutil e cara: o settings.json de projeto só é lido quando a sessão abre naquele diretório.

Eu tinha um hook de notificação declarado no .claude/settings.json da raiz do meu workspace. Só que eu

quase nunca abro o Claude Code na raiz: eu abro dentro de um repositório específico, um nível abaixo. E,

aberta lá, a sessão carrega as skills e rules herdadas, mas não carrega aquele bloco de hooks. Ou seja:

o hook existia, estava correto, e não rodava praticamente nunca. Nenhum erro, nenhum aviso. Só silêncio.

A regra que eu tiro disso é simples. Hook de domínio vai no projeto; hook transversal vai no usuário.

Um guardrail que valida manifesto de Kubernetes só faz sentido no repositório que tem manifesto. Já

notificação não tem nada a ver com o repositório: eu quero ser avisado em qualquer sessão, em qualquer

diretório. Então ela vai no escopo de usuário:

json
// ~/.claude/settings.json
{
  "preferredNotifChannel": "terminal_bell",
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash $HOME/.claude/hooks/notify-desktop.sh",
            "timeout": 10
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash $HOME/.claude/hooks/notify-desktop.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Dois detalhes desse bloco:

Use caminho absoluto. Em hook de projeto é comum escrever $CLAUDE_PROJECT_DIR/.claude/hooks/....

Aqui isso não serve: essa variável aponta para o repositório da sessão atual, que muda a cada vez. Como o

script é único e global, ele precisa de um caminho fixo.

Os settings somam, não substituem. Se o mesmo evento estiver declarado no escopo de usuário e no de

projeto, os dois rodam. É ótimo quando são coisas diferentes: no meu caso, o Stop de usuário notifica e

o Stop do projeto imprime um resumo dos arquivos de infra tocados na sessão, e um não atrapalha o outro.

Vira problema quando é o mesmo hook nos dois lugares: aí você recebe a notificação duas vezes. Ao mover um

hook para o escopo de usuário, lembre de tirar do projeto.

O notificador: por que piscar a barra de tarefas

A escolha óbvia no Windows é o toast, aquele balão no canto inferior direito. Eu comecei por ele e ele não

resolveu, por um motivo que provavelmente vale para muita gente.

O toast morre com o "Não perturbe" ligado. Ele não some: ele entra mudo e direto na Central de

Notificações, onde só aparece se você abrir (Win+N). Como o "Não perturbe" é justamente o que a gente liga

para conseguir trabalhar, o canal que deveria te chamar é o primeiro a ser silenciado. E o som, que seria o

plano B, morre junto se o volume estiver em zero, o que num notebook em reunião é o normal.

O que sobrevive aos dois é a API mais velha da lista: FlashWindowEx, que pisca o botão da janela na

barra de tarefas. Ela não passa pelo subsistema de notificações do Windows, então "Não perturbe" não a

afeta; não emite som, então volume zero não a afeta. E, com a combinação certa de flags, ela pisca até a

janela receber foco: se você voltar dez minutos depois, o ícone ainda está laranja te esperando.

De brinde, ela se autossuprime: chamar FlashWindowEx numa janela que já está em foco não faz nada. Isso

é exatamente o comportamento que a gente quer, porque se o VS Code está em foco você já está olhando.

O script abaixo faz as duas coisas: pisca (canal principal) e dispara o toast (que serve como histórico na

Central de Notificações, mesmo entrando mudo).

powershell
# ~/.claude/hooks/notify-desktop.ps1
param(
  [string]$Title = 'Claude Code',
  [string]$Body  = 'Precisa da sua atencao'
)

$SkipWhenFocused = $true

$ErrorActionPreference = 'SilentlyContinue'

Add-Type @'
using System;
using System.Runtime.InteropServices;
public class ClaudeNotify {
  [StructLayout(LayoutKind.Sequential)]
  public struct FLASHWINFO {
    public uint cbSize; public IntPtr hwnd; public uint dwFlags; public uint uCount; public uint dwTimeout;
  }
  [DllImport("user32.dll")] static extern bool FlashWindowEx(ref FLASHWINFO pwfi);
  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();

  const uint FLASHW_ALL = 3;        // titulo + botao na barra de tarefas
  const uint FLASHW_TIMERNOFG = 12; // pisca ate a janela receber foco

  public static void FlashUntilFocused(IntPtr hwnd) {
    FLASHWINFO f = new FLASHWINFO();
    f.cbSize = (uint)Marshal.SizeOf(f);
    f.hwnd = hwnd;
    f.dwFlags = FLASHW_ALL | FLASHW_TIMERNOFG;
    f.uCount = 0;
    f.dwTimeout = 0;
    FlashWindowEx(ref f);
  }
}
'@

$window = Get-Process -Name Code -ErrorAction SilentlyContinue |
          Where-Object { $_.MainWindowHandle -ne 0 } |
          Select-Object -First 1

if ($window) {
  if ($SkipWhenFocused -and [ClaudeNotify]::GetForegroundWindow() -eq $window.MainWindowHandle) {
    exit 0
  }
  [ClaudeNotify]::FlashUntilFocused($window.MainWindowHandle)
}

try {
  [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null
  $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(
    [Windows.UI.Notifications.ToastTemplateType]::ToastText02)
  $nodes = $template.GetElementsByTagName('text')
  $nodes.Item(0).AppendChild($template.CreateTextNode($Title)) | Out-Null
  $nodes.Item(1).AppendChild($template.CreateTextNode($Body))  | Out-Null
  [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Microsoft.VisualStudioCode').Show(
    [Windows.UI.Notifications.ToastNotification]::new($template))
} catch { }

exit 0

Uma observação sobre o AppId Microsoft.VisualStudioCode na chamada do toast: o Windows 11 descarta em

silêncio toasts de AppIds que não estão registrados na máquina. O AppId do PowerShell normalmente não está,

e é por isso que muito exemplo de internet "funciona" (a API retorna sucesso) sem nunca mostrar nada.

Usar o AppId de um aplicativo que já é registrado resolve, e de quebra o toast sai com o ícone dele.

Se você quiser o toast furando o "Não perturbe", dá: Configurações → Sistema → Notificações → Não perturbe

→ Definir notificações prioritárias, e adicione o Visual Studio Code. É opcional: o canal principal aqui

não depende disso.

Infográfico dividido em dois painéis. À esquerda, TOAST: o balão do Windows saindo do canto inferior direito, com duas linhas vermelhas marcando que ele entra mudo na Central de Notificações quando o Não Perturbe está ligado e que o som morre com o volume em zero. À direita, FLASHWINDOWEX: o botão do VS Code piscando em laranja na barra de tarefas, com três linhas em ciano marcando que não passa pelo subsistema de notificações, que não emite som e que pisca até a janela receber foco graças às flags FLASHW_ALL e FLASHW_TIMERNOFG. Barra de rodapé com a frase de que o canal que deveria te chamar é o primeiro a ser silenciado

O roteador: o que o hook recebe e como decidir

O script PowerShell sabe como avisar. Falta decidir quando avisar e o quê dizer, e isso vem do JSON

que o Claude Code entrega no stdin do hook.

O evento Notification traz message, title (opcional) e, o campo que interessa,

notification_type:
notification_typeQuando disparaNo script abaixo
permission_promptO agente está pedindo aprovação para uma ferramentaNotifica
agent_needs_inputUm subagente está esperando entradaNotifica
idle_promptO agente terminou e você não digitou nada desde entãoNotifica
agent_completedUm subagente terminouIgnorado: o Stop já cobre o fim do turno
auth_success e outrosAutenticação, quota, avisos internosIgnorado

Já o evento Stop, que dispara no fim de cada turno, traz last_assistant_message, a última mensagem

do agente. Dá para usar a primeira linha dela como corpo da notificação, o que é bem melhor do que um

genérico "terminei": você lê o que aconteceu direto do toast, sem voltar para a janela.

Tratar esses tipos de forma diferente é o que separa uma notificação útil de um incômodo. Aprovação é

urgente; auth_success é ruído e não deveria te interromper nunca.

Antes do script, uma dependência: ele usa jq para ler o JSON, e o Ubuntu do WSL não vem com ele. Como

o script sai em silêncio quando o jq falta (é o comportamento correto para um hook, mas é indistinguível

de "não funcionou"), instale primeiro:

bash
sudo apt install -y jq
bash
#!/usr/bin/env bash
# ~/.claude/hooks/notify-desktop.sh
set +e

# Kill-switches: um geral (se voce ja tiver um para seus hooks) e um so da notificacao.
if [ "${HOOKS_ENABLED}" = "false" ]; then exit 0; fi
if [ "${CLAUDE_NOTIFY}" = "off" ]; then exit 0; fi
command -v powershell.exe >/dev/null 2>&1 || exit 0

raw_input="$(cat 2>/dev/null)"
[ -z "$raw_input" ] && exit 0
command -v jq >/dev/null 2>&1 || exit 0

event="$(printf '%s' "$raw_input" | jq -r '.hook_event_name // ""' 2>/dev/null)"

case "$event" in
  Notification)
    ntype="$(printf '%s' "$raw_input" | jq -r '.notification_type // ""' 2>/dev/null)"
    case "$ntype" in
      permission_prompt|agent_needs_input) body="Precisa da sua aprovacao" ;;
      idle_prompt)                         body="Esperando sua resposta" ;;
      *)                                   exit 0 ;;   # auth_success, quota: ruido
    esac
    ;;
  Stop)
    last="$(printf '%s' "$raw_input" \
      | jq -r '(.last_assistant_message // "") | split("\n")[0] | .[0:120]' 2>/dev/null)"
    if [ -n "$last" ]; then body="Terminei: ${last}"; else body="Terminei"; fi
    ;;
  *)
    exit 0
    ;;
esac

# powershell.exe nao enxerga caminho WSL; converter para o caminho Windows.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ps1_win="$(wslpath -w "${script_dir}/notify-desktop.ps1" 2>/dev/null)"
[ -z "$ps1_win" ] && exit 0

body="${body//\'/}"
body="${body//\"/}"

powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$ps1_win" \
  -Title "Claude Code" -Body "$body" >/dev/null 2>&1 &

exit 0

Três decisões nesse script que valem explicação:

wslpath -w. O powershell.exe é um binário Windows: ele não entende /home/voce/.claude/....

Sem a conversão para o caminho Windows, ele simplesmente não acha o arquivo. Vale saber o que sai dessa

conversão, porque não é o que a gente espera: como o script mora no filesystem do WSL, o caminho é UNC

(\\wsl.localhost\Ubuntu\home\voce\...), não C:\.... Só o que está em /mnt/c/ vira letra

de unidade. Para a chamada tanto faz, o PowerShell abre os dois, mas se você for depurar à mão é bom não

estranhar.

O & no fim. Subir um powershell.exe do WSL custa perto de um segundo, e o Add-Type compila

C# em tempo de execução. O hook tem timeout, e segurar o agente por um segundo a cada notificação é um

imposto que não faz sentido pagar. Jogando para background, o hook retorna em milissegundos.

Sair cedo quando falta dependência. Sem jq, sem powershell.exe, sem stdin: exit 0. Hook de

notificação nunca deve quebrar a sessão por causa de si mesmo. O preço desse desenho é o da seção anterior:

falta de dependência e funcionamento normal têm exatamente a mesma cara, então instale o jq antes de

concluir que a receita não funciona.

Infográfico do roteamento: no topo, um cartão com o JSON que chega no stdin do hook. Abaixo, um nó divisor que separa por hook_event_name em dois caminhos, Notification e Stop. O caminho Notification abre em cinco cartões de notification_type, com permission_prompt, agent_needs_input e idle_prompt em ciano levando a corpos de notificação, e agent_completed e auth_success em cinza levando a um cartão exit 0 marcado como ruído. O caminho Stop mostra last_assistant_message sendo cortada na primeira linha e nos 120 caracteres. Barra de rodapé com a frase de que tratar esses tipos de forma diferente é o que separa uma notificação útil de um incômodo

Testando — e por que meus dois primeiros testes não valeram nada

Essa parte é a que eu mais quero que fique, porque eu errei aqui.

Escrevi o script, rodei à mão para testar e não vi nada. Rodei de novo, nada. A primeira reação foi achar

que o script estava quebrado. Só que não estava: o teste é que era inválido.

Os dois primeiros testes rodaram com o VS Code em foco. Nessa condição, o script sai antes de piscar (é o

$SkipWhenFocused), e mesmo sem ele a FlashWindowEx não faria nada, porque piscar janela em foco não

é uma operação que existe. Eu tinha testado, com capricho, exatamente a condição em que o comportamento

correto é não acontecer nada.

A segunda tentativa foi disparar com atraso fixo: "dispara em 12 segundos, troque de janela". Também não

valeu: aos 12 segundos eu ainda estava no VS Code, lendo a mensagem que mandava eu sair dele.

O teste que presta tira o tempo da equação. Em vez de contar segundos, ele espera o foco sair e dispara

no instante em que sai:

powershell
# espera o VS Code perder o foco e so entao dispara
$code = Get-Process -Name Code | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
$deadline = (Get-Date).AddSeconds(90)
while ((Get-Date) -lt $deadline) {
  if ([ClaudeNotify]::GetForegroundWindow() -ne $code.MainWindowHandle) {
    [ClaudeNotify]::FlashUntilFocused($code.MainWindowHandle)
    'Disparado.'
    break
  }
  Start-Sleep -Milliseconds 800
}

E os caminhos do hook em si, com o VS Code minimizado ou em outra tela:

bash
# pedido de aprovacao
echo '{"hook_event_name":"Notification","notification_type":"permission_prompt"}' \
  | bash ~/.claude/hooks/notify-desktop.sh

# fim de turno, com a mensagem real no corpo
echo '{"hook_event_name":"Stop","last_assistant_message":"Terminei o refactor."}' \
  | bash ~/.claude/hooks/notify-desktop.sh

# tipo de ruido: nao deve notificar nada
echo '{"hook_event_name":"Notification","notification_type":"auth_success"}' \
  | bash ~/.claude/hooks/notify-desktop.sh

A lição que sobra é maior que o script: a API retornar sucesso não é verificação. No caminho do toast,

o Windows aceitou a notificação quatro vezes seguidas e devolveu OK nas quatro, e eu não vi nenhuma

delas, porque o "Não perturbe" mandava todas, mudas, direto para a Central de Notificações. "O sistema

aceitou" e "a pessoa foi avisada" são duas afirmações diferentes, e só a segunda é o objetivo. Vale para

notificação e vale para praticamente tudo em infraestrutura: o Succeeded do control plane não prova que

a coisa funciona, prova que o pedido foi aceito.

Kill-switch e detalhes finais

Duas coisas para fechar.

O kill-switch. Em dia de foco absoluto, export CLAUDE_NOTIFY=off silencia só a notificação, sem

derrubar os hooks de guardrail. Separar os dois interruptores importa: você nunca quer estar numa situação

em que a única forma de calar a notificação é desligar as travas de segurança junto.

Reabra o Claude Code. O settings.json é lido na abertura da sessão. Depois de mexer nos hooks, a

sessão que já está aberta continua com a configuração antiga, o que gera aquele minuto de confusão

achando que nada funcionou.

E se um dia parar de funcionar sem motivo aparente, o suspeito é a barra de tarefas com ocultação

automática: piscar um botão que não está na tela não avisa ninguém.

No fim, o que essa configuração faz é pequeno: um ícone que pisca. Mas ela devolve os dezenove minutos do

começo do artigo, e faz isso sem depender de som, sem depender de notificação ligada e sem exigir que você

fique de olho na janela. Para um ciclo em que o humano só entra nos pontos onde ele é obrigatório, o que

importa é que esses pontos sejam curtos.

Logo LowOps, canal de Rafael Ferreira