Setting Up a Claude Code PostToolUse Hook That Auto-Formats Every File It Edits

Close-up photograph of a black mechanical computer keyboard

A Claude Code PostToolUse hook runs a shell command every time Claude’s Write or Edit tool successfully changes a file, which makes it the right place to auto-format code the moment it’s saved rather than batching formatting into a separate step. This walkthrough sets one up for Prettier, using the exact JSON structure and exit-code behavior documented by Anthropic, and covers the two mistakes that make people think the hook “isn’t working” when it actually is.

What PostToolUse actually fires on

Claude Code’s hooks documentation defines PostToolUse plainly: it runs “after a tool call succeeds.” That’s a narrower trigger than it sounds — it does not fire if the edit fails, and it does not fire on tools that only read. The companion event, PreToolUse, fires before the call and can block it; PostToolUse fires only once the file is already on disk, which is exactly what you want for a formatter, since there’s no point formatting a write that never happened.

Hooks are matched by tool name using a filter called matcher, and the documentation’s own example for this exact use case uses Edit|Write so the hook runs after either tool. Hook configuration lives in one of several files — ~/.claude/settings.json for every project, .claude/settings.json if you want it checked into the repo and shared with your team, or .claude/settings.local.json if you want it gitignored and personal.

Step 1: write the formatter script

PostToolUse hooks receive the edited file’s path as part of their input JSON on stdin, but the simplest version for a single-language repo just re-runs Prettier on whatever changed. Create .claude/hooks/format.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))")
if [ -z "$FILE_PATH" ]; then exit 0; fi
case "$FILE_PATH" in
  *.js|*.jsx|*.ts|*.tsx|*.json|*.css|*.md) npx prettier --write "$FILE_PATH" 1>&2 ;;
  *) exit 0 ;;
esac

Make it executable with chmod +x .claude/hooks/format.sh. The case statement matters more than it looks: running Prettier against a file type it doesn’t understand, or against a huge generated file, is the single most common reason people say the hook “broke their file” — it didn’t, Prettier just has nothing useful to do with a .py file and may exit with a confusing error.

Step 2: register the hook

This is the part lifted almost directly from Anthropic’s own documentation, which shows this exact shape for “lint after edits.” Add it to .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh", "timeout": 30 }
        ]
      }
    ]
  }
}

Three details here come straight from the documented schema and are worth getting right. First, matcher sits one level above the hooks array, not inside each handler — a flat object with both keys at the same level is a common copy-paste error. Second, ${CLAUDE_PROJECT_DIR} is a real variable the documentation defines specifically so a hook command works the same way regardless of where someone cloned the repo, instead of hardcoding an absolute path that breaks for every teammate but you. Third, the documentation lists three nesting levels in general — event, then matcher group, then handler — and this config uses all three correctly: PostToolUse is the event, the object with matcher is the group, and the array under hooks holds one or more handlers.

Before and after: what actually changes on disk

Say Claude writes this to utils.js during a task:

function add(a,b){return a+b}
const x = add(1,2)

The Write tool call succeeds, PostToolUse fires, the hook script runs Prettier against utils.js, and by the time Claude’s next turn reads the file back (or the user opens it), it looks like this:

function add(a, b) {
  return a + b;
}
const x = add(1, 2);

Nothing in Claude’s own output changed — the model never sees or writes the formatted version directly. The formatting happens entirely outside the model’s turn, which is the actual point of using a hook here instead of asking Claude to “please format your code”: a hook runs the same way every time, costs no tokens, and can’t be skipped because the assistant got busy with something else.

The exit code that most setups get wrong

The documentation’s exit-code table for PostToolUse says something specific: a non-zero exit normally “shows stderr to Claude” because “the tool already ran” — the hook can’t un-run the edit, it can only report back. The documentation adds a direct instruction for this event: “To surface a warning to Claude from a PostToolUse or PostToolUseFailure hook, exit 2 instead so Claude sees the stderr even though the tool already ran.”

In practice, that means the script above should end with an explicit check instead of letting Prettier’s own exit code pass through silently:

npx prettier --write "$FILE_PATH"
if [ $? -ne 0 ]; then
  echo "Prettier failed on $FILE_PATH" 1>&2
  exit 2
fi

Without that block, a syntax error that makes Prettier fail will fail silently from Claude’s point of view — the file stays unformatted, nothing shows up in the transcript, and the next thing you notice is messy code three edits later with no idea which one caused it. Exit 2 is what makes the failure visible in the same turn it happened.

Common mistakes and how they show up

  • Using PreToolUse instead of PostToolUse by habit. Most published Claude Code hook examples online are PreToolUse ones (blocking dangerous commands, confirming deletes), so it’s an easy copy-paste mistake. A formatter registered under PreToolUse runs before the file is written and has nothing to format yet.
  • Forgetting the file-extension filter. Running Prettier against every single edited file, including lockfiles, generated bundles, or files in node_modules if they’re ever touched, wastes the hook’s 30-second timeout and can make Claude Code feel sluggish after every edit.
  • Not quoting $FILE_PATH. A file path with a space in it (common in markdown docs, less common in code) silently breaks an unquoted shell variable into two arguments. Always quote it.
  • Testing the hook by editing settings.json and expecting it to apply mid-session. Hook configuration is read when the session starts; a change to the hook definition itself generally needs a new session to take effect, even though the script file it calls can be edited freely in between.

Checking that it actually fired

Because PostToolUse hooks run silently in the background by design, the fastest way to confirm one is wired up correctly is to make it loud on purpose during setup. Temporarily add a line to the top of format.sh that appends a timestamp to a log file outside the repo, such as echo "$(date) ran on $FILE_PATH" >> /tmp/claude-hook.log, then ask Claude to edit any matching file and tail that log in a separate terminal. If the line never appears, the problem is almost always one of three things: the settings file has a JSON syntax error that made WordPress-style silent failure (Claude Code won’t error loudly on a malformed hooks block, it just treats the hook as absent), the script isn’t executable, or the session was started before the hook was added to settings and needs to be restarted.

Once you’ve confirmed the hook fires, remove the debug line — leaving it in place means every formatting pass also does a disk write to a log file, which is harmless but unnecessary once you trust the setup.

Why PostToolUse beats a separate lint-and-fix step

The alternative most teams start with is asking Claude to run the formatter itself as a last step, or relying on a pre-commit hook to catch it before the change ever reaches a reviewer. Both work, but both depend on someone remembering to do them. A PostToolUse hook runs on every single qualifying edit inside the session, including ones Claude makes as a small side effect of a larger task that nobody is specifically reviewing yet. That’s also why the timeout matters: the documentation’s own plugin example sets a 30-second timeout for exactly this kind of formatting hook, long enough for Prettier on one file, short enough that a hung process doesn’t stall the whole session.

If your project already blocks dangerous shell commands with a PreToolUse hook, or loads git status at the start of every session with a SessionStart hook, this PostToolUse formatter sits alongside them without conflict — they fire on different events and don’t interact. For catching secrets before they’re committed in the first place, see our walkthrough of configuring Gitleaks as a pre-commit hook.

For a team working across more than one language, the same pattern extends by adding more case branches rather than more hook entries — one PostToolUse handler with a matcher of Edit|Write can route Python files to black, Go files to gofmt, and everything else to Prettier from inside the same script, which keeps the settings.json block itself simple even as the formatting logic grows. Committing that script and its settings entry to .claude/settings.json instead of the local-only variant means every teammate who clones the repo gets identical formatting behavior from Claude Code without having to set anything up themselves — the hook travels with the codebase, not with the person running the session.

Sources: Anthropic, Claude Code Hooks reference documentation.

Featured image: “Das Keyboard closeup” by Michael Coté, via Wikimedia Commons, licensed under CC BY 2.0.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top