An AI coding assistant can produce a useful change and still leave a Python file full of distracting formatting differences. A small local hook can surface that problem immediately, while the edit is still in context. The useful outcome is less avoidable cleanup in a code review—not a claim that tidy code is correct.

This walkthrough builds a check-only Claude Code hook with Ruff. It reports formatting drift after selected file edits, preserves the edited file, and leaves the decision to format explicit. The worked example is a quote-summary function for Northstar Studio, a fictional business. All amounts and business details are sample data.

What was actually tested: the Python hook and real Ruff executable passed sixteen local checks on Windows 11 with Python 3.14.3 and Ruff 0.16.10. A separate business unittest passed. Claude Code was unavailable on this machine, so actual Claude hook dispatch and the model’s response were not tested. The final section explains the host check still required before relying on this in a Claude session.

Keep the feedback loop small

The Claude Code hooks reference documents PostToolUse for successful tool calls and Edit|Write as a tool matcher. Command hooks receive JSON; hookSpecificOutput.additionalContext returns feedback. With args present, the handler uses an executable and argument list rather than shell parsing.

Our design checks only regular .py files resolving beneath this example’s src directory. It fixes the Ruff configuration path, runs the interpreter from the chosen virtual environment, and does not insert the filename into a shell command. This scope is deliberately small enough to understand and test.

Ruff’s formatter documentation distinguishes checking from writing. In check mode, exit 0 means no formatting changes are needed, 1 means changes are needed, and 2 indicates an error. The inspected 0.16.10 implementation handles check results separately from source writes. Ruff is MIT licensed, with third-party notices.

Build the example in a scratch project

The commands below are for Windows PowerShell. Use a disposable folder with Python available as python; this example does not require a paid model call. Internet access is needed to install the pinned Ruff wheel. Claude Code is only needed for the later integration check.

Create this layout. The complete contents of each file appear below. Keep settings.example.json inactive until the local tests pass; do not copy it into your global Claude settings.

scratch-project/
  requirements.txt
  ruff.toml
  settings.example.json
  test_hook.py
  test_quote.py
  tools/
    format_hook.py
  src/
    quote_summary.py
Pinned dependency — requirements.txt
ruff==0.16.10
Formatting configuration — ruff.toml
line-length = 88
target-version = "py311"
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe -m ruff --version

The executed environment used ruff 0.16.10. The two-line configuration specifies an 88-character line length and Python 3.11 syntax targeting. That target setting is not a test that the business application works on every Python version; this run used Python 3.14.3.

Start with a real, intentionally untidy function

Save this as src/quote_summary.py. It multiplies quantities by unit amounts in cents and counts quote lines. The formatting is intentionally cramped. The sample is only a hook fixture: it does not implement input validation, tax, discounts or a production quoting system.

Fictional quote function — src/quote_summary.py
"""Fictional Northstar Studio quote; all amounts are sample cents."""
def quote_summary(items):
    return {"line_count":len(items),"total_cents":sum(item["quantity"]*item["unit_cents"] for item in items)}

Save this independent business expectation as test_quote.py. Two units at 12,500 cents plus one at 5,000 cents must total 30,000 cents. The test checks behavior regardless of how the source is spaced.

Business expectation — test_quote.py
"""A small business expectation for fictional Northstar Studio."""
import unittest
from src.quote_summary import quote_summary


class QuoteTest(unittest.TestCase):
    def test_sample_quote_total(self):
        items = [
            {"quantity": 2, "unit_cents": 12500},
            {"quantity": 1, "unit_cents": 5000},
        ]
        self.assertEqual(quote_summary(items), {"line_count": 2, "total_cents": 30000})


if __name__ == "__main__":
    unittest.main()
.venv\Scripts\python.exe -m unittest test_quote

Check the edited file and return plain feedback

Save the hook as tools/format_hook.py. It derives its root from its own location, so running it with src as the working directory does not change the configuration it uses. A missing Ruff installation gets a separate check: otherwise Python’s module-not-found exit code could be confused with Ruff’s formatting-drift exit code.

Complete formatting hook — tools/format_hook.py
"""Report formatting drift after an Edit/Write; never change source files."""

import json
from importlib.util import find_spec
from pathlib import Path
import subprocess
import sys


ROOT = Path(__file__).resolve().parents[1]


def feedback(message):
    print(
        json.dumps(
            {
                "hookSpecificOutput": {
                    "hookEventName": "PostToolUse",
                    "additionalContext": message,
                }
            }
        )
    )


def main():
    try:
        event = json.load(sys.stdin)
        if not isinstance(event, dict):
            raise ValueError("Expected an event object")
    except (ValueError, UnicodeError):
        print("Formatting hook: invalid input JSON object", file=sys.stderr)
        return 1

    if event.get("hook_event_name") != "PostToolUse" or event.get("tool_name") not in {
        "Edit",
        "Write",
    }:
        print("{}")
        return 0

    tool_input = event.get("tool_input")
    name = tool_input.get("file_path") if isinstance(tool_input, dict) else None
    if not isinstance(name, str) or not name:
        feedback("Formatting check unavailable: missing file_path.")
        return 0
    try:
        target = Path(name)
        if not target.is_absolute():
            feedback("Formatting check unavailable: file_path must be absolute.")
            return 0
        target = target.resolve(strict=True)
        if (
            not target.is_relative_to(ROOT / "src")
            or target.suffix != ".py"
            or not target.is_file()
        ):
            print("{}")
            return 0
        if find_spec("ruff") is None:
            feedback(
                "Formatting check unavailable: Ruff is not installed in this Python environment."
            )
            return 0
        result = subprocess.run(
            [
                sys.executable,
                "-m",
                "ruff",
                "format",
                "--check",
                "--no-cache",
                "--config",
                str(ROOT / "ruff.toml"),
                "--",
                str(target),
            ],
            cwd=ROOT,
            capture_output=True,
            text=True,
            timeout=10,
        )
    except (OSError, ValueError, subprocess.TimeoutExpired):
        feedback(
            "Formatting check unavailable: inspect the path, environment and timeout; "
            "no successful formatting result was obtained."
        )
        return 0

    if result.returncode == 0:
        print("{}")
    elif result.returncode == 1:
        feedback(
            "Ruff found formatting drift in the edited src Python file. "
            "Review and format that file, then rerun the formatting check and "
            "business tests. This hook has not changed the file."
        )
    else:
        feedback(
            "Ruff could not complete the formatting check "
            f"(exit {result.returncode}). Run Ruff directly to inspect syntax, "
            "configuration or installation errors; do not treat this as a pass."
        )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Clean or out-of-scope events print an empty JSON object. Drift produces a short report; syntax or setup failures produce an unavailable/error report. The script intentionally returns exit 0 when it successfully emits that advisory JSON. That is the hook’s reporting status, not proof that the edited code passed. Malformed input exits 1 with a diagnostic on stderr.

The ten-second subprocess timeout bounds an individual Ruff check. The handler below allows fifteen seconds for the whole Python process. Those values are design choices, not measured performance guarantees. The tests did not force a hung process or validate Claude’s timeout behavior.

Test the hook before activating it

Save the proposed Windows handler as settings.example.json. Its interpreter path avoids depending on whichever Python happens to be first on PATH in an agent session.

Proposed Windows hook settings — settings.example.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.venv/Scripts/python.exe",
            "args": ["${CLAUDE_PROJECT_DIR}/tools/format_hook.py"],
            "timeout": 15
          }
        ]
      }
    ]
  }
}

Now save test_hook.py. The harness copies the hook and configuration into a temporary project whose path contains spaces, supplies synthetic tool-event JSON, and runs the actual Python/Ruff processes. It substitutes the documented handler placeholders itself. That exercises our command arguments, input handling and file checks; it does not reproduce Claude’s host implementation.

Complete isolated checks — test_hook.py
"""Exercise the real hook and Ruff in a disposable copy, without Claude."""

import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile

HERE = Path(__file__).resolve().parent


def run_tests():
    results = []
    settings = json.loads((HERE / "settings.example.json").read_text())
    handler = settings["hooks"]["PostToolUse"][0]["hooks"][0]
    assert settings["hooks"]["PostToolUse"][0]["matcher"] == "Edit|Write"
    with tempfile.TemporaryDirectory(prefix="hook check ") as temp:
        root = Path(temp) / "fictional studio"
        (root / "tools").mkdir(parents=True)
        (root / "src").mkdir()
        shutil.copy2(HERE / "tools/format_hook.py", root / "tools/format_hook.py")
        shutil.copy2(HERE / "ruff.toml", root / "ruff.toml")
        # Substitute the actual venv prefix; no Claude process is involved.
        command = handler["command"].replace("${CLAUDE_PROJECT_DIR}/.venv", sys.prefix)
        args = [s.replace("${CLAUDE_PROJECT_DIR}", str(root)) for s in handler["args"]]
        env = dict(os.environ, PYTHONUTF8="1")

        def invoke(
            file, *, tool="Write", event="PostToolUse", raw=None, executable=None
        ):
            payload = {
                "hook_event_name": event,
                "tool_name": tool,
                "tool_input": {"file_path": str(file)},
                "cwd": str(root),
            }
            p = subprocess.run(
                [executable or command, *args],
                input=raw if raw is not None else json.dumps(payload),
                text=True,
                capture_output=True,
                cwd=root / "src",
                env=env,
                timeout=15,
            )
            parsed = json.loads(p.stdout) if p.stdout else None
            return p, parsed

        def record(name, p, output):
            results.append(
                {
                    "check": name,
                    "passed": True,
                    "exit": p.returncode,
                    "stdout": output,
                    "stderr": p.stderr.strip(),
                }
            )

        target = root / "src/quote summary.py"
        target.write_bytes((HERE / "src/quote_summary.py").read_bytes())
        before = hashlib.sha256(target.read_bytes()).hexdigest()
        for tool in ("Write", "Edit"):
            p, out = invoke(target, tool=tool)
            assert p.returncode == 0 and "formatting drift" in json.dumps(out)
            assert hashlib.sha256(target.read_bytes()).hexdigest() == before
            record(tool + " reports drift without rewriting", p, out)

        p = subprocess.run(
            [
                sys.executable,
                "-m",
                "ruff",
                "format",
                "--no-cache",
                "--config",
                str(root / "ruff.toml"),
                str(target),
            ],
            capture_output=True,
            text=True,
            check=True,
        )
        (HERE / "formatted-example.py").write_bytes(target.read_bytes())
        p, out = invoke(target)
        assert p.returncode == 0 and out == {}
        record("explicit formatting clears the report", p, out)
        formatted_hash = hashlib.sha256(target.read_bytes()).hexdigest()
        p, out = invoke(target)
        assert (
            out == {}
            and hashlib.sha256(target.read_bytes()).hexdigest() == formatted_hash
        )
        record("repeated clean check preserves bytes", p, out)

        for name, file, tool, event in [
            ("Bash event ignored", target, "Bash", "PostToolUse"),
            ("wrong event ignored", target, "Write", "PreToolUse"),
            ("non-Python file ignored", root / "src/notes.txt", "Write", "PostToolUse"),
            (
                "file outside project ignored",
                Path(temp) / "outside.py",
                "Write",
                "PostToolUse",
            ),
            (
                "Python outside src ignored",
                root / "tools/other.py",
                "Write",
                "PostToolUse",
            ),
        ]:
            if file != target:
                file.write_text("def broken(:", encoding="utf-8")
            saved = file.read_bytes()
            p, out = invoke(file, tool=tool, event=event)
            assert p.returncode == 0 and out == {} and file.read_bytes() == saved
            record(name, p, out)

        target.write_text("def broken(:\n", encoding="utf-8")
        p, out = invoke(target)
        assert p.returncode == 0 and "could not complete" in json.dumps(out)
        assert target.read_text() == "def broken(:\n"
        record("syntax failure is not a formatting pass", p, out)
        for name, file in [
            ("missing file reported", root / "src/missing.py"),
            ("relative path rejected", Path("src/quote_summary.py")),
        ]:
            p, out = invoke(file)
            assert p.returncode == 0 and "unavailable" in json.dumps(out)
            record(name, p, out)

        p, out = invoke(target, raw="{bad json")
        assert p.returncode == 1 and out is None and "invalid input" in p.stderr
        record("malformed event reported", p, out)

        empty_env = Path(temp) / "empty environment"
        subprocess.run(
            [sys.executable, "-m", "venv", "--without-pip", str(empty_env)], check=True
        )
        p, out = invoke(target, executable=str(empty_env / "Scripts/python.exe"))
        assert p.returncode == 0 and "Ruff is not installed" in json.dumps(out)
        record("missing Ruff is not misreported as style drift", p, out)

        # A formatted implementation can still be wrong for the business.
        target.write_text(
            'def quote_summary(items):\n    return {"total_cents": 0}\n',
            encoding="utf-8",
        )
        p, out = invoke(target)
        assert p.returncode == 0 and out == {}
        namespace = {}
        exec(compile(target.read_text(), str(target), "exec"), namespace)
        sample = [
            {"quantity": 2, "unit_cents": 12500},
            {"quantity": 1, "unit_cents": 5000},
        ]
        assert namespace["quote_summary"](sample)["total_cents"] != 30000
        record(
            "formatted wrong total passes formatting, fails business expectation",
            p,
            out,
        )

        namespace = {}
        exec(
            compile((HERE / "formatted-example.py").read_text(), "example", "exec"),
            namespace,
        )
        assert namespace["quote_summary"](sample) == {
            "line_count": 2,
            "total_cents": 30000,
        }
        results.append(
            {
                "check": "correct fixture retains sample total 30000 cents",
                "passed": True,
            }
        )

    version = subprocess.check_output(
        [sys.executable, "-m", "ruff", "--version"], text=True
    ).strip()
    evidence = {
        "python": platform.python_version(),
        "platform": platform.platform(),
        "ruff": version,
        "passed": len(results),
        "checks": results,
        "claude_installed_on_path": shutil.which("claude") is not None,
        "claude_session_tested": False,
        "invocation": "Documented exec-form placeholder expansion simulated locally; real Python hook and Ruff executed in paths with spaces, from src cwd.",
        "limits": "No Claude dispatch/model response, worktree session, timeout injection, hostile concurrent filesystem or CI service tested.",
    }
    (HERE / "test-evidence.json").write_text(
        json.dumps(evidence, indent=2) + "\n", encoding="utf-8"
    )
    print(json.dumps(evidence, indent=2))


if __name__ == "__main__":
    run_tests()
.venv\Scripts\python.exe -X utf8 test_hook.py

The harness writes test-evidence.json and formatted-example.py in the scratch project. It leaves the original src/quote_summary.py unchanged. The executed result was 16 passing checks:

Fixture Observed result
Write and Edit with an untidy Python file Drift reported; source hashes unchanged.
Explicitly formatted file, then a repeated check Empty JSON response; repeat preserves bytes.
Bash, a different event, text file, outside-project file, Python outside src Ignored, with source bytes preserved.
Invalid Python syntax Ruff error reported, not a pass.
Missing file, relative path, malformed JSON, environment without Ruff Corresponding unavailable or input-error result.
Neatly formatted function returning zero for every quote Formatting passes; the 30,000-cent expectation fails.
Correct fixture after explicit formatting Two lines and 30,000 cents retained.

The zero-total example is the most useful result. A formatting tool can accept a business mistake perfectly. Pair its output with tests for the decisions that matter in your application: totals, permissions, allowed transitions and duplicate handling. For data entering a business system, the Pydantic validation walkthrough addresses a different part of that problem.

Make formatting an explicit action

In this scratch project, inspect the changes the formatter would make, apply them to the one file, then run both checks:

.venv\Scripts\python.exe -m ruff format --diff --config ruff.toml src/quote_summary.py
.venv\Scripts\python.exe -m ruff format --config ruff.toml src/quote_summary.py
.venv\Scripts\python.exe -m ruff format --check --config ruff.toml src
.venv\Scripts\python.exe -m unittest test_quote

The formatter’s diff can exit nonzero when it finds differences; inspect that output rather than treating it as a successful release check. Use the final format check and business tests as separate required steps in your own review or CI workflow. No hosted CI configuration was deployed or tested for this article.

Finish the Claude integration check in your own session

Record claude --version. In the disposable project, merge the proposed handler into .claude/settings.json, preserving existing keys and hook arrays. The official hooks guide describes /hooks for confirming registration. If an organization permits only managed hooks, project hooks will not run. Current documentation was checked October 3, 2026; an older installed client may need different handling.

Ask Claude to use its Edit or Write tool to make a deliberately untidy change to the scratch Python file. Confirm the tool used, then confirm the hook’s drift message actually appears. Repeat after explicit formatting and check that the report clears. Until those observations exist, you have a tested local program and a proposed integration—not verified Claude automation. Do not infer registration from the presence of a JSON file.

Edit|Write does not catch changes made through Bash or external editors. A post-tool hook also cannot undo the edit that triggered it. Those are documented hook boundaries; run the whole-source formatting check and business tests before accepting work even when the hook is quiet.

This example checks one project root and one source directory. A session that changes worktrees needs a separate path check; our local tests did not exercise that transition. If you use isolated AI coding worktrees, verify the interpreter, hook and target file all belong to the intended checkout before adopting this configuration there.

Choose this hook when immediate formatting feedback would make review easier. Prefer just a pre-commit or CI check when edit-time feedback adds noise. Automatically formatting after every edit is another design, but it changes files during the agent’s work and needs its own tests. This article makes no claim about time saved, model obedience or production correctness.

Jason Ead publishes AlgoThoughts AI News and connects practical engineering with My Biz Heroes: business websites, automation, workflows, AI tools and training. Read Jason’s profile for the publication’s business connection.

Research and tool-executed tests: October 3, 2026. Fictional business and sample amounts. Featured visual is a generated conceptual illustration, not a product screenshot or a customer deployment.