Blog · AI Harness

Hooks do Claude Code: como configurar, com 3 exemplos prontos (PreToolUse, PostToolUse e Stop)

Capiva · Outubro 2026 · 10 min de leitura

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:

  1. Um hook de PreToolUse que nega edição em migração de banco já versionada no git.
  2. Um de PostToolUse que roda ruff format e ruff check --fix em todo .py editado e devolve ao Claude o que sobrou.
  3. Um de Stop que 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}.py

Se 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 matcher tem duas leituras. Se só tem letras, dígitos, _, -, espaço, vírgula e |, é comparação exata: Edit|Write casa 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. Com args, o Claude Code executa o binário direto (exec form), sem shell, e cada item vira um argumento. Sem args, a string vai pra sh -c. A documentação recomenda args sempre 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=0

No 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 /hooks se 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 echo no ~/.bashrc carregado via BASH_ENV. Procure Hook output does not start with { no log de debug.
  • [ ] O permissionDecision não faz efeito. Está no nível de cima do JSON em vez de dentro de hookSpecificOutput. O log mostra Hook JSON output had unrecognized keys.
  • [ ] O caminho do script não resolve. Use ${CLAUDE_PROJECT_DIR} com args, 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": true no settings, ou claude --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 PATH do 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

Quer esses guards prontos e testados no seu repositório? Conheça o capivaOS.