Claude Code hooks to enforce your architecture
Two Claude Code hooks: lint every file the agent edits, and send it back when it tries to finish with failing checks. Scripts, settings.json, edge cases.
Hooks are commands Claude Code runs at fixed points in a session — before a
tool runs, after it runs, when Claude tries to stop. Unlike instructions in
CLAUDE.md, they run every time, whatever the model decides. That makes them
the right place for rules that must never break.
Code for this guide: https://github.com/KonixDev/claude-code-guardrails — the open-source (MIT) claude-code-guardrails kit.
This guide covers two hooks that make an architecture rule hard to drift past:
- After every edit, lint the file Claude just touched and hand the errors straight back to it.
- When Claude tries to finish, run lint + typecheck and, if they fail, send it back with the output — once.
The rules themselves live in ESLint (here, layer boundaries for a modular SaaS); the hooks make sure the agent hits them while it works, not three commits later in CI.
The contract, in two sentences
From the Claude Code hooks reference:
- A PostToolUse hook that exits with code 2 shows its stderr to Claude. It can't undo the edit — the tool already ran — so Claude reads the error and fixes it next.
- A Stop hook that exits with code 2 prevents Claude from stopping; the
stderr becomes the reason it keeps working. The input includes
stop_hook_active, which istruewhen Claude is already continuing because of a stop hook. Letting that second stop through is the loop guard — and it means this hook is one forced retry, not a guarantee. CI stays the hard gate.
Every hook receives a JSON object on stdin. For an edit it looks like this (trimmed):
{
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": { "file_path": "/repo/src/modules/billing/domain/invoice.ts" },
"cwd": "/repo"
}Wire them up
.claude/settings.json, committed to the repo:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/lint-edited-file.mjs\"",
"timeout": 60
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/verify-before-stop.mjs\"",
"timeout": 300
}
]
}
]
}
}$CLAUDE_PROJECT_DIR is the project root, so the hooks work from any
subdirectory. Quote it: paths with spaces are common on laptops. The scripts are
Node rather than bash so they don't depend on jq being installed.
Hook 1: lint the file Claude just edited
// .claude/hooks/lint-edited-file.mjs
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
const LINTABLE = /\.(c|m)?(t|j)sx?$/;
const CONFIGS = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts'];
const input = JSON.parse(readFileSync(0, 'utf8') || '{}');
const edited = input.tool_input?.file_path;
if (!edited || !LINTABLE.test(edited) || !existsSync(edited)) process.exit(0);
// Real paths on both sides: ESLint silently ignores a file it thinks lies
// outside its cwd, which is what a symlinked checkout (or macOS /var →
// /private/var) looks like when the two paths are spelled differently.
const file = realpathSync(edited);
const root = realpathSync(resolve(process.env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd()));
/** True when `p` is `root` or inside it (separator-aware: `/app-evil` is not inside `/app`). */
const insideRoot = (p) => {
const rel = relative(root, p);
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
};
// Only lint files inside the project. ESLint configs and plugins are code, and
// a file elsewhere (/tmp, another repo) could pull in one we must not execute.
if (!insideRoot(file)) process.exit(0);
/** Walk up from `start` to `root` (inclusive), never above it; first dir where `test` holds. */
function findUp(start, test) {
for (let dir = start; insideRoot(dir); dir = dirname(dir)) {
if (test(dir)) return dir;
if (dir === root) break;
}
return null;
}
// ESLint 9 resolves its flat config from the working directory, so in a
// monorepo lint from the package that owns the config, not the repo root.
const cwd = findUp(dirname(file), (d) => CONFIGS.some((c) => existsSync(join(d, c)))) ?? root;
// Run ESLint's own entry point with this Node, so the hook does not depend on
// PATH, npx, or a shell shim resolving `node`.
const ESLINT_JS = join('node_modules', 'eslint', 'bin', 'eslint.js');
const eslintDir = findUp(cwd, (d) => existsSync(join(d, ESLINT_JS)));
const eslint = process.env.ESLINT_BIN ?? (eslintDir && join(eslintDir, ESLINT_JS));
// No ESLint installed is a setup problem, not an architecture violation:
// never block the agent on it.
if (!eslint || !existsSync(eslint)) process.exit(0);
const result = spawnSync(process.execPath, [eslint, '--no-warn-ignored', file], {
cwd,
encoding: 'utf8',
});
if (result.status === 0) process.exit(0);
if (result.error || result.status === null) process.exit(0); // ESLint itself could not run
process.stderr.write(
`Architecture guardrail failed for ${file}. Fix this before continuing — do not disable the rule:\n\n` +
`${result.stdout}${result.stderr}`,
);
process.exit(2);Decisions worth copying:
- Lint one file, not the repo. It runs after every edit, so keep it fast (type-aware rules make even one file slower).
- Ignore what isn't code, and what isn't yours. Markdown, JSON, deleted files and anything outside the project exit 0. ESLint configs are code; never load one from a directory you don't control.
- Lint from the right package. ESLint 9 resolves its flat config from the
working directory, so in a monorepo the hook runs from the package that owns
the edited file's
eslint.config.*. - Never block on a broken setup. No ESLint installed is not a violation: exit 0, or every edit gets blocked with an empty error.
- Say what not to do. Put "do not disable the rule" in the message itself.
Otherwise
// eslint-disable-next-lineis the shortest path to silence.
Hook 2: you're not done until it's green
// .claude/hooks/verify-before-stop.mjs
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const input = JSON.parse(readFileSync(0, 'utf8') || '{}');
// Claude is already continuing because of this hook: let the second stop
// through, so a failure it cannot fix never becomes an infinite loop. This
// makes the hook one forced retry, not a guarantee — CI is the hard gate.
if (input.stop_hook_active) process.exit(0);
const root = process.env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd();
let command = process.env.GUARDRAILS_CMD;
if (!command) {
// Default: the project's `guardrails` script. No script, nothing to enforce —
// never block a project that has not finished setting the kit up.
const pkgPath = join(root, 'package.json');
const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf8')) : {};
if (!pkg.scripts?.guardrails) process.exit(0);
command = 'npm run -s guardrails';
}
const result = spawnSync(command, { cwd: root, shell: true, encoding: 'utf8' });
if (result.status === 0) process.exit(0);
const output = `${result.stdout}${result.stderr}`.trim().split('\n').slice(-60).join('\n');
process.stderr.write(
`Guardrails are failing (\`${command}\`). The task is not done until they pass. ` +
`Fix the cause; do not weaken lint rules or add ignores:\n\n${output}\n`,
);
process.exit(2);guardrails is a script you define — keep it to lint + typecheck
("guardrails": "eslint . && tsc --noEmit"). Leave the full test suite to CI:
this runs every time Claude tries to finish. Without the script, the hook
exits 0 instead of blocking every stop with "Missing script".
What it looks like in a real session
We asked Claude Code to do the wrong thing on purpose — add a method to a
domain entity that imports the database adapter from infrastructure/. The
hook output and the agent's replies are published in the kit's
verified session log:
- Claude made the edit. The PostToolUse hook fired and Claude received
domain/ may not import application/, infrastructure/ or interfaces/as feedback right after the tool call. - Because the request explicitly demanded the import, Claude tried to wrap up. The Stop hook ran the guardrails, they failed, and Claude was sent back with the lint output.
- Claude's final answer: it could not complete the change without breaking the architecture rule, and it was not marking the task as done.
That last line is the point: the agent did not quietly report success with a boundary violation in the diff. (The request demanded the violation, so "blocked, here are your options" was the right outcome; we have not measured how often an ordinary request ends up on the port-and-adapter route.)
Edge cases
- The second stop is let through. If Claude can't fix the failure — or
only half-fixes it —
stop_hook_activelets it finish. That is why CI, not the hook, is the hard gate. - Slow guardrails. If lint + typecheck takes minutes in a large monorepo,
point
GUARDRAILS_CMDat a narrower script (changed packages only). - Teammates without Node on PATH. Hooks run in your shell environment. If
noderesolves to something else (a Bun shim, an old nvm default), use an absolute path in the command. - Hooks are code. They run with your permissions. Review them like any other script you commit, and approve project hooks deliberately.
Get the kit
Both hooks, the ESLint boundary config they rely on, AGENTS.md/CLAUDE.md,
a review subagent and a Cursor rule are in
claude-code-guardrails
(MIT). Its tests feed each hook the same stdin Claude Code sends and assert the
exit codes. For what to put in the prose half, see
a CLAUDE.md example for a SaaS codebase.
FAQ
PreToolUse or PostToolUse for linting?
PostToolUse. A PreToolUse hook sees the edit before it happens, but for an
Edit that is a string replacement, not the final file, so you can't lint it
reliably. Linting the result and feeding the error back works with every tool.
Can a hook stop Claude from making an edit at all?
A PreToolUse hook can block a tool call (exit code 2). Use it for hard
prohibitions you can check from the input alone — editing generated files,
touching .env. For "does this code respect the architecture", lint after.
Does this work in Cursor or Codex?
These scripts are written for Claude Code's hook format. Cursor has its own
hooks (.cursor/hooks.json, with events such as afterFileEdit and stop)
with a different input and output, so they would need a port. The ESLint rules
and the CI gate work everywhere; see Cursor rules for Next.js and
TypeScript.
Why is MultiEdit in the matcher?
Older Claude Code versions had a separate MultiEdit tool. Keeping it in the
list is harmless.
useDeploy ships both hooks, AGENTS.md and the ESLint boundaries on a full
SaaS base — auth, organizations, billing. See the modules.