claudeplugins.
claudeplugins6 min read

PostToolUse Hooks in Claude Code: Auto-Format and Auto-Test Without the Noise

Wire a PostToolUse hook to run prettier or pytest after every Write/Edit, parse the JSON decision protocol, and use suppressOutput to keep the transcript clean.

PostToolUse Hooks in Claude Code: Auto-Format and Auto-Test Without the Noise

Claude Code edits files. Your CI cares about formatting, lint, and tests. The cheapest way to bridge the two is a PostToolUse hook: a shell command the agent runs after every Write, Edit, or MultiEdit, with structured JSON in and structured JSON out. Done right, your agent self-corrects on a failed prettier run before you ever see the diff. Done wrong, your transcript becomes a wall of formatter chatter.

This piece walks the contract end-to-end: the input payload, the JSON decision protocol, the suppressOutput flag, and a working bash script you can drop into .claude/settings.json today.

Why PostToolUse over PreToolUse

Both hooks fire on tool calls, but they answer different questions.

PreToolUse runs before the tool executes. Its job is gating \u2014 block a Bash command that touches ~/.ssh/, refuse a Write to /etc/, etc. The agent never sees the result of the tool because the tool never ran.

PostToolUse runs after the tool executes. The tool already changed the file. Now the hook gets to react: format it, lint it, run a smoke test, or tell the agent "you broke something, here's the error, fix it."

For auto-format and auto-test, PostToolUse is the right hook 100% of the time. You want the file written first, then formatted in place, then either approved silently or sent back to the agent with a complaint. That feedback loop is the whole point.

The input payload

When a PostToolUse hook fires, Claude Code spawns your command and pipes a JSON object to stdin. The shape:

{
  "session_id": "abc-123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/path/to/your/project",
  "hook_event_name": "PostToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/path/to/your/project/src/index.ts",
    "old_string": "...",
    "new_string": "..."
  },
  "tool_response": {
    "filePath": "/path/to/your/project/src/index.ts",
    "success": true
  }
}

The two fields you actually use: tool_name (filter to Write|Edit|MultiEdit) and tool_input.file_path (the file you want to format). Everything else is for logging or advanced flows.

The output protocol

Your hook talks back through three channels, in increasing power:

  1. Exit code 0, no stdout \u2014 silent success, agent continues. This is what you want 90% of the time.
  2. Exit code 2, stderr \u2014 non-blocking warning. Agent sees the stderr, decides whether to react.
  3. Exit code 0, JSON on stdout \u2014 structured decision. This is the lever for "tell the agent to fix it."

The JSON output schema:

{
  "decision": "block",
  "reason": "prettier failed: src/index.ts:42 unexpected token",
  "suppressOutput": true
}

decision: "block" is the magic \u2014 Claude reads reason and treats it as a signal to revise. suppressOutput: true hides the JSON itself from the user-visible transcript so your terminal isn't drowning in hook chatter.

A working auto-format hook

Here's a bash script that formats TypeScript with prettier and TypeScript-checks with tsc, blocking on errors:

#!/usr/bin/env bash
# .claude/hooks/post-edit-format.sh
set -euo pipefail

input=$(cat)

tool_name=$(echo "$input" | jq -r '.tool_name')
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')

# Only act on Write/Edit/MultiEdit
case "$tool_name" in
  Write|Edit|MultiEdit) ;;
  *) exit 0 ;;
esac

# Only TypeScript files
case "$file_path" in
  *.ts|*.tsx) ;;
  *) exit 0 ;;
esac

# Format in place; capture failure
if ! prettier_out=$(npx prettier --write "$file_path" 2>&1); then
  jq -n --arg reason "prettier failed on $file_path: $prettier_out" \
    '{decision: "block", reason: $reason, suppressOutput: true}'
  exit 0
fi

# Type check just this file's project
if ! tsc_out=$(npx tsc --noEmit 2>&1); then
  jq -n --arg reason "tsc errors after edit: $tsc_out" \
    '{decision: "block", reason: $reason, suppressOutput: true}'
  exit 0
fi

# Silent success
exit 0

Wire it in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/post-edit-format.sh"
          }
        ]
      }
    ]
  }
}

The matcher field is a regex against tool_name. The script double-checks anyway because hooks are cheap to run but expensive to debug when they silently misfire.

suppressOutput: when to use it

suppressOutput: true hides the hook's JSON output from the transcript view. Use it when:

  • The hook succeeded silently (no point cluttering the log)
  • The hook blocked with a clear reason (Claude needs the reason; the human doesn't need the raw JSON)
  • The output would be repetitive (e.g. prettier --check running on every single edit in a 50-file refactor)

Skip suppressOutput (or set it to false) when:

  • You're debugging why a hook is or isn't firing
  • The hook is doing something the user genuinely wants to see (a "tests passed" green checkmark on every commit-ready edit)

The default is false, which means without the flag the JSON shows up. For production hooks, set it to true everywhere except your debug runs.

Auto-test as a different pattern

Auto-test deserves its own consideration. Running pytest on every Write is overkill \u2014 a 30-second test suite times out the hook (default 60s, configurable per-hook). Better patterns:

  • Scope by path: only run tests for the package containing the edited file. Map src/foo/bar.py to tests/foo/test_bar.py and run that one file.
  • Run only fast tests: tag your suite with pytest -m "not slow" in the hook; let CI run the slow ones.
  • Run a syntax check only: python -m py_compile <file> in 50ms beats pytest in 30s and catches the most common breakage class \u2014 typos.

Here's the syntax-only Python variant:

#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
[[ "$file_path" == *.py ]] || exit 0

if ! err=$(python3 -m py_compile "$file_path" 2>&1); then
  jq -n --arg reason "syntax error: $err" \
    '{decision: "block", reason: $reason, suppressOutput: true}'
fi

That hook runs in under 100ms per edit. Compare to a 30-second full pytest run that times out half the time and the math gets obvious: 300\u00d7 faster, catches 80% of the same regressions.

Watch the failure modes

Three things bite people when they first wire a PostToolUse hook:

  1. Hook timeout. The default 60s hard cap means a slow tsc --noEmit on a 500-file project will be killed mid-run, leaving the agent in a confused state. Configure timeout per-hook in settings if you need more.
  2. Recursive edits. A hook that calls back into Claude Code (e.g. via the SDK) can trigger another PostToolUse and loop. Don't do that. Hooks should call deterministic CLI tools, not other agents.
  3. decision: "block" on a flaky linter. If your formatter sometimes fails on transient network issues (yes, some linters fetch rules), every flake becomes a fake "fix this" signal to the agent. Pin tool versions, run offline, and only block on errors that are genuinely the agent's fault.

For a polyglot monorepo, the script above expands cleanly: branch on file extension, dispatch to language-specific commands, exit silent on no-op. Keep each hook under 200 lines and under 5 seconds.

References: