You want ESLint or Ruff to run every time Claude Code edits a file, so lint errors show up before they reach a pull request. You open the hooks docs and find nested arrays, event names, matcher patterns and a command field. One misplaced bracket or a wrong event name, and the hook silently does nothing.
This article compares three ways to get a working file: a form-based builder, asking Claude to write it, and hand-writing it from the docs. For each, you get the trade-offs for a small team, plus a checklist to confirm the hook is valid and actually fires after an edit.
What users report
Community threads suggest there is no single generator for this job. Users mostly describe writing the hook config by hand and then tuning it, though some share tools or examples to make it easier.
What works
- One Reddit post describes hooks as "if then" rules made of an event, a matcher and a command, written in plain JSON. Its examples include running a formatter after every write and running tests after file edits.
- Another poster lists running your linter after every file edit as a practical use for hooks.
- Users who wanted an easier way to manage several hooks shared A tool to manage Claude Code Hooks.
- Set up these 4 Claude Code hooks gives copyable patterns for the event, matcher and command structure.
What goes wrong
- Users ask whether several PostToolUse hooks run in a fixed order. They do not: Claude Code's documentation says all matching hooks run in parallel.
- One user who blocks Claude from editing its own skills and hooks asks whether the model can still work around those guards, in Any Way for Claude Code to Circumvent.
Tips users share
- Scope hooks to specific files with matchers instead of running a command on everything, as a monorepo thread describes.
- Use a PreToolUse hook when you need to stop a tool call before it runs. PostToolUse fires after the edit, so it can report problems but not undo them.
- Don't rely on the order of several hooks in one array, because they run in parallel.
What's the easiest way to generate a valid settings.json for Claude Code linter hooks?
For a first valid file, a form-based hooks builder is usually the easiest route, because it removes the need to remember event names, matcher syntax and the JSON structure. Once that file exists, hand-edit it for rules specific to your team. The right choice depends on how well you already know the hooks schema.
Claude Code hooks are configured in a settings file as events (such as PostToolUse) with a matcher (such as Edit or Write) and a command to run. A linter hook is a small config, but a misplaced bracket, a misspelled event name or a wrong matcher can leave it silently doing nothing.
The three options compared
| Criteria | Form-based hooks builder | Asking Claude to write it | Hand-writing from the official docs |
|---|---|---|---|
| Setup time | Short: fill in fields, copy output | Short: one prompt, then review | Longest: read docs, write, test |
| Schema validity risk | Low, since structure is generated from fields | Medium: output can look plausible but use wrong keys or nesting | Depends on care; typos are the main risk |
| Needs knowledge of event names and matchers | Little; options are presented to you | Some; you need enough to spot mistakes | Yes, fully |
| Best for | Getting a first working file quickly | Developers who want to iterate in conversation and can verify the result | People who want full control and already know the schema |
| Not for | Highly custom setups beyond the form's fields | Anyone who can't check the output against the docs | Anyone in a hurry or new to hooks |
Who each option fits
- Builder: developers new to hooks, or anyone who wants a valid file without reading the schema first. It is a weaker fit when you need unusual logic.
- Asking Claude: people comfortable reviewing JSON who want to tweak the result through follow-up prompts. Treat the output as a draft and check it against the documentation.
- Hand-writing: those who know the event and matcher model, or who need to understand every line they maintain.
Recommendation
Start with a builder to get a valid baseline that runs your linter after edits. Then edit the file by hand to add team-specific rules, such as limiting the matcher to certain tools or changing the command. Validating the JSON and making a test edit confirms the hook actually fires.
What does a valid settings.json for a lint-after-edit hook look like?
A valid file has a top-level hooks key, a PostToolUse event inside it, a matcher that names the editing tools, and a hook entry of type command. The file normally lives at .claude/settings.json in the project.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx eslint --fix",
"timeout": 60
}
]
}
]
}
}
How the pieces fit
hooks: the top-level key that holds all hook definitions.PostToolUse: the event that fires after a tool call finishes. It maps to an array of matcher groups.matcher: a pattern for tool names.Edit|Writecovers edits to existing files and new files.type: "command": tells Claude Code to run a shell command.command: the string that gets executed. This one reads the edited file's path from the hook input withjqand lints only that file.timeout: optional, in seconds, so a stuck linter cannot hang the session.
Note the nesting: each matcher group contains its own hooks array. A common mistake is putting the command object directly under PostToolUse.
Example linter commands
- JavaScript or TypeScript:
npx eslint --fixfollowed by the file path - Python:
ruff check --fixfollowed by the file path
The hook receives its input as JSON on stdin. That JSON describes the tool call, including the edited file path in tool_input.file_path. Linting only that file keeps each edit fast; a whole-project command such as npx eslint . also works but slows down on large repos. The example needs jq installed.
Why PostToolUse and not PreToolUse
PreToolUse runs before the edit happens, so the linter would check the file as it was, not as it will be. Linting at that point finds nothing about the change that was just proposed.
PostToolUse runs after the file is written, so the linter sees the new contents. Any --fix changes are applied to the edited file, and linter output can surface problems right after the change that caused them.
Which Claude Code hook event and exit code should a linter use?
Use a PostToolUse hook matched to the file-editing tools, and have the linter exit with code 2 when it finds problems. PostToolUse fires after a tool call succeeds, so the file is already written when the linter runs. Exit code 2 sends stderr back to Claude, which can then fix the issues.
Exit codes
| Exit code | Behavior |
|---|---|
| 0 | Success. Claude does not see the output. |
| 2 | Stderr is sent to Claude so it can fix the problem. On PostToolUse this cannot block anything, because the edit already happened. |
| Other non-zero | Non-blocking error. You see a hook error notice, Claude does not get the output, and the session continues. |
Many linters exit with 1 on violations. That shows a notice but does not give the output to Claude, so a small wrapper script that converts failures to exit 2 is often needed:
#!/bin/bash
file=$(jq -r '.tool_input.file_path')
npx eslint --fix "$file" >&2 || exit 2
Save it as .claude/hooks/lint.sh, make it executable, and set the hook's command to that path.
Hook types
Claude Code supports several hook types, including command, http, prompt and agent. A linter should use command: it runs a shell command, is deterministic, and its exit code and stderr map directly to the behavior above. Prompt and agent hooks rely on a model's judgment, which suits fuzzy checks but adds latency and variability to a task a linter already does.
Blocking with PreToolUse
PostToolUse cannot undo an edit. To stop one before it happens, use PreToolUse. A command hook can read the tool input from stdin, check the file path or command, and exit 2 to block the call and send the reason to Claude.
#!/bin/bash
input=$(cat)
if echo "$input" | grep -qE '\.env|package-lock\.json|rm -rf'; then
echo "Blocked: protected file or destructive command." >&2
exit 2
fi
exit 0
Related events
- Stop runs when Claude finishes responding. It suits a final, whole-project check, not per-edit linting.
- Notification runs when Claude Code sends a notification, such as when it needs permission or is waiting for input. It suits desktop alerts or sounds, not lint feedback.
How do you configure hooks in Claude Code step by step?
Create a .claude/settings.json file in your project, add a hooks block with a PostToolUse entry, then confirm it with the /hooks command and trigger an edit to watch it run. The steps below follow that order.
- Create the file. In the project root, make a
.claudefolder and addsettings.jsoninside it. Committing this file shares the hook with everyone working in the repository.
- Paste the hooks block. Add a
hooksobject with aPostToolUsearray, anEdit|Writematcher so the hook fires after file changes, and acommandhook that calls your own linter, such asnpx eslint --fixorruff check --fix, on the edited file.
- Let Claude Code pick it up. Edits to settings files are normally picked up automatically while a session is running. If the hook doesn't appear in the next step, restart the session.
- Confirm registration. Run
/hooksinside Claude Code. It lists configured hook events and their matchers, so you can check that PostToolUse shows your entry.
- Trigger an edit. Ask Claude to change a file that matches your linter's scope. The linter should run right after the edit, and its output appears in the session.
If nothing runs, check the JSON for syntax errors first, such as trailing commas or mismatched braces. Then verify the matcher spelling, since tool names are case-sensitive, and run the linter command by hand in a terminal to make sure it works outside Claude Code.
Should you commit settings.json or keep it local?
Keep personal preferences separate from the shared project configuration. That split keeps individual preferences from leaking into the shared configuration.
Claude Code reads settings from several levels:
| File | Scope | Commit it? |
|---|---|---|
.claude/settings.json | Project, shared with the team via git | Yes |
.claude/settings.local.json | Per-user overrides for one project | No |
~/.claude/settings.json | User-level, applies across all repos | No (lives in your home directory) |
| Managed settings | Organization-wide enforcement | Deployed by admins |
What belongs where
Put the shared linter hook in the project file. Anyone who clones the repo gets the same behavior after every edit, with no setup steps to forget.
Use the local file for things like a different timeout, an extra personal hook, or experiments you don't want to impose on others. Use the user-level file for habits you want in every repo, such as a notification hook.
If your organization needs rules that individuals can't skip, managed settings are the place to enforce them.
Make sure the linter is actually installed
A committed hook only works if the command it calls exists on every machine. Make sure the linter is installed for everyone who uses the repo, so the hook behaves the same way on each machine.
If you are also standardizing agents, a subagent file generator can help you produce consistent definitions to commit alongside your settings.
How can you use a hooks builder to generate the file?
A hooks builder lets you assemble the hooks visually and outputs a valid settings.json, so you skip hand-writing the JSON. The Claude Code Hooks Builder does this, and it is one of several free Claude Code tools available.
The workflow is short:
- Pick a starting point in the builder, such as the auto-format preset, which already runs a command after every edit.
- Change the command to your linter, so the PostToolUse hook runs it after file edits.
- Save the output as
.claude/settings.jsonin your project.
The tools are free, need no signup, and nothing leaves your browser, so your linter commands and project paths stay on your machine.
Review before you commit
A generated file is a starting point, not a guarantee that it fits your repo. Before committing, check two things:
- The linter command: confirm it matches what you run locally, such as the right binary, flags and file arguments.
- The matcher: make sure it targets only the editing tools you want, so the linter does not fire on unrelated tool calls.
Run one edit in Claude Code afterward and confirm the hook fires as expected. Then the file is ready to share with your team.
How do you debug a linter hook that does not run?
Start by checking the file itself, then the matcher, then the command. Most silent hook failures come from invalid JSON, a matcher that does not match the tool name, or a linter the hook shell cannot find.
1. Validate the JSON
A trailing comma, a missing quote or a comment will make the whole settings file invalid. Run it through a JSON validator or a command such as python -m json.tool .claude/settings.json. If it prints an error with a line number, fix that first.
2. Check the matcher and tool name
Tool names are capitalized. Edit|Write will match those tools, while edit or write will not. Confirm the event is PostToolUse, since a lint-after-edit hook needs to fire once the file has changed.
3. Confirm the linter is on PATH
The hook runs in a non-interactive shell, so it may not load the same profile as your terminal. A linter installed through a version manager or a virtual environment can work by hand and fail in the hook. Use an absolute path, or call it through your project runner such as npx or python -m.
4. Run the command manually with sample JSON
Hook commands receive event data as JSON on stdin. Reproduce that by piping a sample payload into the command:
echo '{"tool_input":{"file_path":"src/app.py"}}' | ./lint-hook.sh
echo $?
Check the output and the exit code. If the script parses the file path with a tool like jq, make sure that tool is installed too.
5. Keep slow linters from blocking edits
A linter that scans the whole repository after every edit slows each change. Lint only the file named in the payload, or only changed files, and move full runs to CI or a pre-commit step. Set the hook's timeout field, in seconds, so a stuck linter cannot stall the session.
Related articles
Frequently asked questions
Can one settings.json run different linters for different file types?
Yes. The hook command receives JSON on stdin, so a small script can read tool_input.file_path and branch on the file extension. For example, it can run eslint for .ts files and ruff for .py files, and skip anything else.
Will a linter hook slow Claude Code down on large repos?
It can if the hook lints the whole project, so lint only the edited file instead. Also set a timeout on the hook so a stuck linter cannot stall the session. Speed varies by linter, config and machine, so measure your own single-file run with time before deciding whether it is fast enough.
How do I keep hooks consistent across several repos?
Commit a project-level settings.json in each repo so every teammate gets the same hooks. Keep hooks in ~/.claude/settings.json only if they need to apply on your own machine, since user-level files are not shared with the team. Name the one file teammates should never edit locally, so everyone knows which copy is the source of truth.
Is it safe to let Claude edit its own settings.json hooks?
Treat it with care. Hooks run shell commands with your user permissions, so a bad command can do real damage. Review any generated command line by line, and avoid anything containing rm, curl piped to sh, or network calls before you commit the file.