Um hook no Claude Code é um comando (ou requisição HTTP, ou prompt) que o próprio Claude Code executa quando um evento do ciclo de vida acontece: antes de uma ferramenta rodar (PreToolUse), depois que ela roda (PostToolUse), quando o agente quer encerrar o turno (Stop), e cerca de três dezenas de outros. O hook recebe um JSON no stdin, responde com código de saída e, opcionalmente, um JSON no stdout. É código determinístico, não instrução: ele roda sempre, não importa o que o modelo decidiu.
Este post é um dos guias práticos do guia de Claude Code, que explica onde os hooks entram na arquitetura do harness (guides, sensors, o loop de erro para regra). Aqui é só o como: três hooks reais, testados, em Python puro, sem jq.
O que vamos montar:
- Um hook de
PreToolUseque nega edição em migração de banco já versionada no git. - Um de
PostToolUseque rodaruff formateruff check --fixem todo.pyeditado e devolve ao Claude o que sobrou. - Um de
Stopque não deixa o Claude encerrar o turno com teste vermelho.
Crie a estrutura de pastas
Hooks de projeto ficam em .claude/settings.json, que vai pro git e vale pra todo o time. Os scripts ficam ao lado:
mkdir -p .claude/hooks
touch .claude/settings.json
touch .claude/hooks/{migracoes_imutaveis,ruff_pos_edicao,testes_antes_de_parar}.pySe o hook é só seu, use .claude/settings.local.json (não versionado) ou ~/.claude/settings.json (vale pra todos os seus projetos). As configurações de níveis diferentes se somam: um hook de usuário não apaga um hook de projeto. A lista completa de lugares, incluindo plugins e frontmatter de skills, está na referência de hooks.
Registre os hooks no settings.json
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/migracoes_imutaveis.py"],
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff_pos_edicao.py"],
"timeout": 60
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/testes_antes_de_parar.py"],
"timeout": 300
}
]
}
]
}
}Três detalhes que economizam tempo:
- O
matchertem duas leituras. Se só tem letras, dígitos,_,-, espaço, vírgula e|, é comparação exata:Edit|Writecasa exatamente essas duas ferramentas. Qualquer outro caractere transforma o matcher em regex JavaScript sem âncora. Por isso, pra pegar todas as ferramentas de um servidor MCP, o padrão émcp__memory__.*, com o.*. - Use
args. Comargs, o Claude Code executa o binário direto (exec form), sem shell, e cada item vira um argumento. Semargs, a string vai prash -c. A documentação recomendaargssempre que o comando referencia um placeholder de caminho como${CLAUDE_PROJECT_DIR}, porque não há aspas pra escapar. - Mantenha o
$schema. Com ele, o editor valida o arquivo e completa os campos. Validei o arquivo acima contra o schema do SchemaStore antes de publicar.
O timeout padrão de um hook command é 600 segundos. Coloque um menor. Um guard de PreToolUse que leva 10 segundos já é lento demais.
Escreva o guard de PreToolUse
O hook recebe no stdin um JSON com tool_name, tool_input e cwd, entre outros campos. Pra Edit e Write, o caminho do arquivo está em tool_input.file_path.
#!/usr/bin/env python3
"""PreToolUse: nega Edit/Write em migração que já está no git."""
import json
import subprocess
import sys
from pathlib import Path
evento = json.load(sys.stdin)
caminho = evento.get("tool_input", {}).get("file_path", "")
if "/migrations/" not in caminho.replace("\\", "/"):
sys.exit(0) # não é migração: sem decisão, segue o fluxo normal
# Arquivo novo em migrations/ pode. Só bloqueia o que o git já rastreia.
rastreado = (
subprocess.run(
["git", "ls-files", "--error-unmatch", caminho],
check=False,
cwd=evento.get("cwd") or Path(caminho).parent,
capture_output=True,
).returncode
== 0
)
if rastreado:
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"{Path(caminho).name} já está versionada e pode ter rodado em produção. "
"Não edite migrações existentes: crie uma migração nova com a alteração."
),
}
},
ensure_ascii=False,
)
)
sys.exit(0)A decisão vai dentro de hookSpecificOutput, com permissionDecision valendo allow, deny, ask ou defer. O permissionDecisionReason de um deny é mostrado ao Claude, então escreva como instrução: diga o que fazer em vez do que foi bloqueado. Um deny de PreToolUse bloqueia a ferramenta até em modo bypassPermissions.
Repare no que o script não faz: ele não sai com código 1 achando que isso bloqueia. Não bloqueia. Pela referência, só o exit 2 é bloqueante. Exit 0 é sucesso (e o stdout é lido como JSON), qualquer outro código é erro não bloqueante, e a ferramenta roda mesmo assim.
Quando não usar hook
Se a regra fosse "nunca edite nada em migrations/", sem a condição do git, o lugar certo seria a permissão, não o hook:
{
"permissions": {
"deny": ["Edit(migrations/**)"]
}
}A própria documentação diz pra usar o sistema de permissões "rather than a hook to enforce a hard allow or deny". E atenção: regras de caminho usam Edit(...). Uma regra Write(migrations/**) é aceita, mas nunca é consultada. Hook é pra lógica condicional, que depende de estado (o git, o horário, a branch).
Escreva o sensor de PostToolUse
#!/usr/bin/env python3
"""PostToolUse: formata o .py editado e devolve ao Claude o que o lint não resolveu."""
import json
import subprocess
import sys
evento = json.load(sys.stdin)
caminho = evento.get("tool_input", {}).get("file_path", "")
if not caminho.endswith(".py"):
sys.exit(0)
subprocess.run(["ruff", "format", "--quiet", caminho], check=False)
lint = subprocess.run(
["ruff", "check", "--fix", "--quiet", "--output-format=concise", caminho],
check=False,
capture_output=True,
text=True,
)
if lint.returncode != 0:
# Em PostToolUse, exit 2 não desfaz a edição: mostra o stderr ao Claude.
print(
f"ruff encontrou problemas em {caminho}:\n{lint.stdout[-3000:]}",
file=sys.stderr,
)
sys.exit(2)
sys.exit(0)Em PostToolUse a ferramenta já rodou, então o exit 2 não desfaz nada: ele entrega o stderr ao Claude como feedback, e o Claude corrige na próxima ação. É o sensor clássico. O que o ruff conserta sozinho (formatação, imports) some sem gastar token. Só o que precisa de julgamento, como uma variável indefinida, volta pro modelo. O corte em 3.000 caracteres existe porque saída de hook tem teto de 10.000 caracteres; acima disso, o Claude Code grava o texto em arquivo e manda só uma prévia.
Escreva o gate de Stop
#!/usr/bin/env python3
"""Stop: não deixa o Claude encerrar o turno com teste vermelho."""
import json
import subprocess
import sys
evento = json.load(sys.stdin)
r = subprocess.run(
["python3", "-m", "pytest", "-q", "-x", "--no-header"],
check=False,
cwd=evento.get("cwd"),
capture_output=True,
text=True,
)
if r.returncode in (0, 5): # 5 = nenhum teste coletado
sys.exit(0)
saida = r.stdout[-2000:]
if evento.get("stop_hook_active"):
# Já bloqueamos uma vez e o Claude não resolveu: deixa parar, mas avisa você.
resposta = {"systemMessage": "Claude parou com testes falhando:\n" + saida}
else:
resposta = {
"decision": "block",
"reason": "Os testes falharam. Corrija antes de encerrar:\n" + saida,
}
print(json.dumps(resposta, ensure_ascii=False))decision: "block" com reason faz o Claude continuar trabalhando, com o motivo como próxima instrução. O campo stop_hook_active chega true quando o Claude já está continuando por causa de um Stop hook. O exemplo da documentação sai quando ele é true. Preferi não sair calado: o script deixa o turno acabar, mas manda um systemMessage, que aparece pra você e não pro modelo. Assim um teste quebrado nunca termina em silêncio.
Mesmo que você esqueça essa checagem, o Claude Code tem uma trava: depois de 8 continuações seguidas causadas por Stop hooks, ele ignora o próximo bloqueio e encerra o turno (o limite muda com CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). Não conte com ela. Oito voltas de suíte de teste custam caro.
Teste cada hook antes de abrir o Claude Code
Hook é um programa que lê stdin. Teste como programa:
echo '{"hook_event_name":"PreToolUse","tool_name":"Edit","cwd":"'"$PWD"'",
"tool_input":{"file_path":"'"$PWD"'/migrations/0001_inicial.py"}}' \
| python3 .claude/hooks/migracoes_imutaveis.py; echo "exit=$?"{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "0001_inicial.py já está versionada e pode ter rodado em produção. Não edite migrações existentes: crie uma migração nova com a alteração."}}
exit=0No repositório de exemplo, montei um script que cria um repo git temporário e roda 8 casos: migração versionada, migração nova, arquivo comum, .py só mal formatado, .py com erro que o ruff não corrige, testes verdes, testes vermelhos e testes vermelhos com stop_hook_active. Os 8 se comportaram como esperado.
Depois, dentro do Claude Code, /hooks mostra o que foi carregado e de onde (o menu é só leitura; edição é no JSON). Pra ver exit code, stdout e stderr de cada execução, inicie com claude --debug-file /tmp/claude.log e acompanhe com tail -f.
Como usamos na Capiva
O capivaOS, harness open source que usamos nos projetos, registra um guard de PreToolUse em Edit|MultiEdit|Write|NotebookEdit e outro em Bash|PowerShell, os dois com timeout de 10 segundos (hooks.json). Duas decisões de lá que eu recomendaria pra qualquer time:
- A regra saiu do prompt. O ADR-0008 registra que um "nunca faça X" no prompt não é guardrail, e moveu a aplicação pra um hook.
- Falha aberta, mas barulhenta. Se o próprio guard quebra, ele deixa passar e escreve um aviso alto no stderr, além de gravar um heartbeat em arquivo pra dar pra ver se ele está rodando. Um guard que bloqueia todo o trabalho quando tem bug tende a ser desligado pelo time, e aí não protege nada.
Checklist de troubleshooting
- [ ] O hook não dispara. Confira em
/hooksse ele foi carregado e de qual arquivo. Confira se o matcher é exato (Edit|Write) e se o nome da ferramenta está certo, com maiúscula. - [ ] O bloqueio não bloqueia. Você saiu com exit 1? Só exit 2 bloqueia. Ou use o JSON com
permissionDecision: "deny"e exit 0. - [ ] O JSON é ignorado sem erro. Algo escreveu no stdout antes do JSON, normalmente um
echono~/.bashrccarregado viaBASH_ENV. ProcureHook output does not start with {no log de debug. - [ ] O
permissionDecisionnão faz efeito. Está no nível de cima do JSON em vez de dentro dehookSpecificOutput. O log mostraHook JSON output had unrecognized keys. - [ ] O caminho do script não resolve. Use
${CLAUDE_PROJECT_DIR}comargs, não caminho relativo: o hook pode rodar com outro diretório atual. - [ ] O Stop entra em loop. Trate
stop_hook_active. - [ ] Precisa desligar tudo agora.
"disableAllHooks": trueno settings, ouclaude --settings '{"disableAllHooks": true}'só pra uma execução. Hooks de managed settings continuam rodando. - [ ] Python ou ruff "não encontrado". Exec form procura o binário no
PATHdo processo do Claude Code, não no do seu venv ativado em outro terminal. No Windows, veja as diferenças de shell em Como instalar o Claude Code no Windows.
Perguntas frequentes
Hook substitui o CLAUDE.md?▼
Não. O CLAUDE.md orienta e o hook garante. Regra que precisa valer sempre vai pro hook; contexto que ajuda o modelo a decidir fica no CLAUDE.md; procedimento usado às vezes vira skill.
Posso escrever hooks em outra linguagem?▼
Pode. Hook do tipo command é qualquer executável que lê JSON do stdin. Também existem hooks http, mcp_tool, prompt e agent.
Hook de PostToolUse consegue desfazer a edição?▼
Não. A ferramenta já rodou. Ele pode devolver feedback (exit 2 ou decision: "block") pra o Claude corrigir na próxima ação.
O hook roda em modo bypassPermissions?▼
Roda. PreToolUse dispara antes de qualquer checagem de modo de permissão, e um deny bloqueia mesmo com --dangerously-skip-permissions.
Leia também
- Claude Code é um harness: CLAUDE.md, hooks, MCP e subagentes (o guia principal)
- Como escrever um CLAUDE.md que o agente segue
- Como criar uma Skill no Claude Code
- Como criar um servidor MCP em Python e configurar no Claude Code
- Como instalar o Claude Code no Windows
- Guardrails e evals: como saber se seu agente acertou
Quer esses guards prontos e testados no seu repositório? Conheça o capivaOS.