Claude Code has three levels of guardrails you can set up to control what it writes: hooks, rules, and (with GitHub Spec Kit) a project constitution. This post covers what each one does, how they differ, and how I use all three on Cookie to enforce ES5 compatibility on a legacy frontend that runs on iPads from 2012.
#My use case
Cookie is a self-hosted recipe manager with two frontends. The modern one is React 19 and TypeScript. The legacy one is vanilla ES5 JavaScript and CSS3, built for iOS 9.3 Safari. I have an iPad 3 in the kitchen that runs iOS 9.3.6, the last update it'll ever get. Mobile Safari on that version supports ES5 and a subset of CSS3. No const, no arrow functions, no template literals, no CSS Grid, no custom properties, no flexbox gap.
Both frontends talk to the same Django API and have full feature parity. When I built the original prototype in 3 days, the legacy frontend was part of the plan from day one. The project now has 37 JavaScript files across the legacy frontend and every one of them has to be strict ES5.
#What happens without guardrails
I wrote the ES5 constraint into my planning documents from the start and Claude Code respected it most of the time. But a fair bit would still slip through, especially if I lapsed into a quick "fix it" prompt instead of being specific or using Spec Kit to get the work properly researched and written up before implementation. Claude would forget the constraints, drop in an arrow function or a let, and I'd catch it during QA.
That worked when Cookie was small. As the legacy frontend grew, "catch it during QA" stopped scaling. I needed something that would block violations automatically, regardless of what I put in the prompt.
The original prototype used a hand-written "Mega-Plan" approach, which I covered in the rapid prototyping post. I later switched to GitHub Spec Kit, which brought a proper constitution and structured specification pipeline. But the real teeth came from hooks.
#Hooks
Claude Code hooks are shell scripts that run automatically before or after Claude uses a tool. They receive JSON on stdin describing what Claude is about to do, and they can either allow it (exit 0) or block it (exit 1). When a hook exits 1, Claude sees the error output and has to fix the problem before it can retry the edit.
The key concept is the matcher. You configure which tool triggers the hook. Edit and Write are the ones that matter for code quality, because those are the tools Claude uses to modify files. Bash is useful for catching commands that shouldn't run on the host.
Here's the full hook configuration from Cookie's .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/es5-syntax-check.sh",
"statusMessage": "Checking ES5 compliance..."
},
{
"type": "command",
"command": ".claude/hooks/ios9-css-check.sh",
"statusMessage": "Checking iOS 9 CSS compatibility..."
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/es5-syntax-check.sh",
"statusMessage": "Checking ES5 compliance..."
},
{
"type": "command",
"command": ".claude/hooks/ios9-css-check.sh",
"statusMessage": "Checking iOS 9 CSS compatibility..."
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/docker-command-check.sh",
"statusMessage": "Checking Docker environment..."
}
]
}
]
}
} Three matchers: Edit, Write, and Bash. The first two run the same two hooks (ES5 syntax and iOS 9 CSS). The Bash matcher runs the Docker environment check. All are PreToolUse, meaning they run before the tool executes. If a hook blocks, the edit never happens. The statusMessage shows up in the terminal while each hook runs.
The following sections walk through each of the three hooks.
#es5-syntax-check.sh
This hook does the most work. It reads the JSON input, extracts the file path and new content, and checks whether the target file is in apps/legacy/static/legacy/js. If it is, the script greps the content for ten categories of ES6+ syntax:
constandletdeclarations- Arrow functions (
=>) - Template literals
asyncandawait- Class declarations
- Object and array destructuring
- Spread operator (
...) - Default parameters
- Object method shorthand
- Object property shorthand (logged as a warning, not blocked, because the heuristic can produce false positives)
If anything matches, the hook collects all violations and prints an error with the ES5 equivalent for each one:
BLOCKED: ES6+ Syntax Detected in Legacy Frontend
================================================
iOS 9 Safari requires ES5 syntax only:
- Found 'const' declaration (use 'var' for ES5)
- Found arrow function '=>' (use 'function()' for ES5)
Common fixes:
const/let -> var
() => {} -> function() {}
`Hello ${name}` -> 'Hello ' + name
async/await -> callbacks or .then()
{x, y} = obj -> var x = obj.x; var y = obj.y;
Claude sees this output, understands what's wrong, and rewrites the edit in ES5. The hook only fires on files in the legacy JavaScript directory. Claude can use whatever modern syntax it wants in the React frontend or Django backend.
#ios9-css-check.sh
This hook is the longest at 261 lines. It checks legacy CSS files in apps/legacy/static/legacy/css for features that don't work on iOS 9 Safari, and it has two severity levels.
Blockers (exit 1) prevent the edit. The full list from the code:
- CSS Grid (
display: grid,grid-template-*,grid-column/grid-row/grid-area,display: inline-grid) position: stickybackdrop-filter(including-webkit-prefixed):focus-visible,:is()/:where(),:has()aspect-ratioandplace-items/place-content/place-self@container,@layer,@supportsclamp()andmin()/max()CSS functionscolor-mix()- Logical properties (
margin-inline,padding-block,inset) - CSS custom properties (
var(--*)) - Flexbox
gap overflow: overlay,scroll-behavior: smooth,overscroll-behavior,accent-color,content-visibility
Warnings log but don't block. Currently just object-fit, which partly works on iOS 9 (fine on <img>, broken on <video>).
When a blocker fires, the hook prints the iOS 9 safe alternative for each blocked feature:
Safe alternatives for iOS 9:
var(--primary) -> #6b8e5f (use literal hex values)
gap: 0.5rem -> .selector > * + * { margin-left: 0.5rem; }
display: grid -> display: -webkit-flex; display: flex
position: sticky -> position: fixed
aspect-ratio: 16/9 -> padding-bottom: 56.25% (on a wrapper)
#docker-command-check.sh
This hook matches the Bash tool (not Edit/Write like the others). Cookie runs entirely in Docker. There's no Python or Node.js on the host. The hook checks whether Claude is trying to run python, manage.py, pytest, pip, or npm without docker compose exec and warns if so.
This one doesn't block (exit 0 always). The warning is enough for Claude to switch to the Docker command. That's Principle VI of the constitution ("Docker Is the Runtime") enforced at the tool level.
#.claude/rules/
Hooks are binary: allow or block. They can tell Claude what it did wrong, but they're not the right place for explaining why something matters or giving detailed guidance on alternative approaches.
That's what Claude Code's rules are for. Rules are markdown files in .claude/rules/ that get injected into Claude's context automatically. They're not enforced mechanically like hooks. They work because Claude reads them and adjusts its behaviour.
There are two ways rules get loaded. Rules without any frontmatter load at the start of every session, so Claude always has them. Rules with a paths: field in their YAML frontmatter only load when Claude reads or edits a file matching the pattern. This matters for context: you don't want all your rules loaded when Claude is editing a README.
Cookie has six rule files totalling 212 lines. Four are path-scoped and two load globally.
es5-compliance.md is scoped to apps/legacy/static/legacy/**. When Claude opens or edits a file in that directory, this rule loads into context with a syntax comparison table (ES6+ forbidden vs ES5 required), common gotchas like variable hoisting and the this context issue when converting arrow functions, and CSS compatibility notes including WebP not being supported on iOS 9 (which the hooks don't check for). When Claude is working on the React frontend or Django backend, this rule stays out of the way.
django-security.md and react-security.md are scoped to their respective stacks (apps//*.py and frontend/src//*.{ts,tsx}). ai-features.md is scoped to the AI, recipes, frontend, and legacy directories.
code-quality.md and docker-environment.md have no paths: frontmatter and load at the start of every session, because quality gates and Docker commands apply everywhere.
The rules and hooks complement each other. A hook tells Claude "you used const, use var". A rule, already loaded into context before Claude starts writing, explains why var is needed and how to handle common conversion patterns. With the rule in context, Claude writes ES5 correctly more often, so the hook fires less.
#The constitution and Spec Kit
Hooks and rules operate at the code level, catching problems as Claude writes code. The constitution operates earlier, at the specification and planning stage, before any code gets written.
Cookie's constitution is a versioned document (currently v1.3.0) with seven principles. GitHub Spec Kit loads it automatically during the specify and plan stages. The one that matters for legacy compatibility is Principle I:
Every feature MUST work on both the modern frontend (React 19, TypeScript, ES2020+) and the legacy frontend (vanilla ES5, CSS3 with iOS 9.3 Safari compatibility). Neither frontend is a "fallback" or degraded experience.
Spec Kit's pipeline runs in stages: specify, clarify, plan, tasks, checklist, analyse. Each specification and each implementation plan has to include a constitution check, a section validating alignment with each principle. If a feature spec doesn't address how it'll work on the legacy frontend, it fails the check before any code gets written. I wrote about the switch to Spec Kit and how it replaced my earlier hand-written phase plans.
The constitution also has a "Responsible Development" section that says developers (human and AI) must fix pre-existing issues as they encounter them. No skipping hooks, no suppressing errors. This matters because Claude will sometimes try adding // eslint-disable comments rather than actually fixing a problem.
The Spec Kit artifacts are committed to the repo. The dual-mode auth spec is a good example of how the constitution check works in practice.
The constitution check in the plan has a row for ES5 legacy parity marked as compliant, with the note "Legacy frontend gets matching auth screens". The spec itself has a scenario for users accessing Cookie from iOS 9 Safari, requiring ES5-compatible login and registration screens with no const, let, arrow functions, or template literals. The task breakdown has a dedicated phase for legacy frontend auth screens, with ES5-only explicitly noted as the requirement.
The legacy auth phase in the plan goes further, specifying that each template (login, registration, settings) must use ES5-only JavaScript and calling out exactly which files need creating. The assumptions state that the legacy frontend is not a second-class citizen, and the constraints repeat the iOS 9.3 Safari requirement. Without these checks built into the spec pipeline, the legacy frontend would quietly fall behind as new features got added.
#How the layers stack
Each layer catches what the previous one missed.
The constitution and Spec Kit catch incompatible features at the specification stage, before any code gets written.
Rules give Claude detailed context about what's allowed and why. When Claude loads es5-compliance.md, it knows to write ES5 from the start. Most edits pass the hooks first time because the rules did their job.
Hooks are the hard stop. No ES6+ syntax lands in legacy files. The edit gets blocked and Claude fixes it on the next attempt.
CI validates everything again. The pipeline has a legacy-lint job running ESLint with ecmaVersion: 5 on all 37 legacy JavaScript files, and a legacy-duplication job for copy-paste detection.
#Does it work
I checked. The hooks were added on 2 February 2026. Since then, 17 commits have touched files in apps/legacy/static/legacy/js/. I grepped every one of those diffs for const, let, arrow functions, template literals, async/await, and class declarations. Zero hits. The current legacy JS files are also clean. So nothing has slipped through to the codebase since the guardrails went in. Whether that's because the hooks blocked attempts or because the rules stopped Claude writing them in the first place, I can't tell. I don't have logs of how many times the hooks fired vs how many edits passed first time.
The whole lot took an afternoon to put together. The hooks are short bash scripts (the ES5 check is 151 lines, the Docker check is 98, and the CSS one is the longest at 261). The rules are markdown files. The constitution is a document that Spec Kit reads automatically. None of it is complicated, but it's worked so far.