Consistency compounds delivery speed. Instead of pasting PM context and coding standards into every chat, encode them once as Claude Code hooks that run before tools or prompts are processed.
This guide shows how to implement pre-prompt middleware using Claude Code Hooks—so every run gets your Definition of Done, acceptance criteria, and repo standards automatically.
We’ll reference the official hooks model (events, matchers, exit codes, JSON outputs) and provide copy-pasteable examples.
What we’ll build
- UserPromptSubmit: inject PM/engineering context and acceptance criteria; block unsafe prompts.
- PreToolUse (Write/Edit/Read/Bash): enforce coding standards, file protections, and command policies; optionally auto-approve/ask/deny.
- PostToolUse: validate outcomes (style, tests, schema), feed issues back to Claude automatically.
- Stop/SubagentStop: require follow-ups (e.g., “tests missing → continue and add tests”).
- SessionStart: preload org/program/repo context into the conversation on startup/resume.
All of this lives in .claude/settings.json (and friends) plus a few small hook scripts in your repo.
Project layout
.prompt/
org/security.md
program/architecture-principles.md
repo/coding-standards.md
repo/testing.md
repo/error-handling.md
repo/observability.md
tasks/AC-1234-add-feature-x.yaml
.claude/
settings.json
hooks/
user-prompt-submit.py
pretool-guard.py
posttool-validate.py
stop-enforcer.py
Hooks can reference project files portably via
"$CLAUDE_PROJECT_DIR".
Configure hooks (settings)
Here’s a minimal .claude/settings.json that wires up our policy:
json{ "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/user-prompt-submit.py", "timeout": 30 } ] } ], "PreToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pretool-guard.py", "timeout": 30 } ] }, { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pretool-guard.py", "timeout": 30 } ] }, { "matcher": "Read", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pretool-guard.py", "timeout": 30 } ] } ], "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/posttool-validate.py", "timeout": 45 } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-enforcer.py", "timeout": 15 } ] } ], "SessionStart": [ { "matcher": "startup|resume", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/user-prompt-submit.py", "timeout": 30 } ] } ] } }
Notes:
matchertargets tools (e.g.,Write|Edit,Bash,Read). Use*to match all.- For UserPromptSubmit/Stop/SessionStart, omit
matcher. - Use exit code 2 to block; or return JSON for granular control.
Hook 1 — Inject PM context & ACs (UserPromptSubmit)
Goal: Add org/program/repo policy summaries and task ACs into the conversation context automatically. Block secrets or disallowed inputs.
./.claude/hooks/user-prompt-submit.py:
python#!/usr/bin/env python3 import json, sys, os, datetime, re from pathlib import Path def read(p): try: return Path(p).read_text(encoding="utf-8") except: return "" def elide(s, n): return s if len(s) <= n else s[:n-3] + "..." def load_task_yaml(): # Simple heuristic: allow user to type AC id in prompt like "Implement AC-1234" # You could also parse from current branch, ticket, etc. prompt = input_data.get("prompt", "") m = re.search(r"\bAC-(\d+)\b", prompt) if not m: return None ac_path = Path(PROJ) / ".prompt" / "tasks" / f"AC-{m.group(1)}-add-feature-x.yaml" return read(ac_path) or None try: input_data = json.load(sys.stdin) except json.JSONDecodeError as e: print(json.dumps({"decision":"block","reason":f"Invalid hook input JSON: {e}"})) sys.exit(0) PROJ = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) # Security: block potential secrets in prompt prompt = input_data.get("prompt", "") if re.search(r"(?i)\b(password|secret|key|token)\s*[:=]", prompt): print(json.dumps({ "decision": "block", "reason": "Security: prompt appears to include a secret. Remove secrets and retry." })) sys.exit(0) org = elide(read(Path(PROJ)/".prompt/org/security.md"), 1600) arch = elide(read(Path(PROJ)/".prompt/program/architecture-principles.md"), 1400) code = elide(read(Path(PROJ)/".prompt/repo/coding-standards.md"), 1800) tests = elide(read(Path(PROJ)/".prompt/repo/testing.md"), 1200) errs = elide(read(Path(PROJ)/".prompt/repo/error-handling.md"), 1200) obs = elide(read(Path(PROJ)/".prompt/repo/observability.md"), 800) ac_yaml = load_task_yaml() or "" context_parts = [] context_parts.append(f"Current time: {datetime.datetime.now()}") if org: context_parts.append(f"Org Security:\n---\n{org}\n---") if arch: context_parts.append(f"Architecture Principles:\n---\n{arch}\n---") if code: context_parts.append(f"Coding Standards:\n---\n{code}\n---") if tests:context_parts.append(f"Testing:\n---\n{tests}\n---") if errs: context_parts.append(f"Error Handling:\n---\n{errs}\n---") if obs: context_parts.append(f"Observability:\n---\n{obs}\n---") if ac_yaml: context_parts.append(f"Acceptance Criteria (YAML):\n---\n{ac_yaml}\n---") context_parts.append("Output contract: Markdown sections in order: Design, API, Code, Tests. If ambiguous, list assumptions and proceed.") additional = "\n\n".join(context_parts) # JSON control: allow prompt, add context print(json.dumps({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": additional } })) sys.exit(0)
Why JSON? For UserPromptSubmit, stdout with exit code 0 also injects context, but JSON gives you precise “block vs allow” control.
Hook 2 — Guard tool use (PreToolUse)
Goal: Enforce repo rules at tool boundaries.
- Write/Edit: prevent touching forbidden paths; require RFC7807 errors; insist on zod validation, etc.
- Read: auto-approve safe docs; ask/deny sensitive files.
- Bash: reject
grep/findpatterns; preferrgor safe wrappers.
./.claude/hooks/pretool-guard.py:
python#!/usr/bin/env python3 import json, sys, os, re from pathlib import Path try: data = json.load(sys.stdin) except Exception as e: print(json.dumps({"continue": False, "stopReason": f"Hook JSON parse error: {e}"})) sys.exit(0) tool = data.get("tool_name", "") inp = data.get("tool_input", {}) or {} def json_out(obj): print(json.dumps(obj)); sys.exit(0) # Helper: block with Claude-facing reason (PreToolUse) def deny(reason): json_out({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason } }) # Helper: require ask/confirm def ask(reason): json_out({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": reason } }) # Helper: auto-approve safe operations def allow(reason=None): out = { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": reason or "Approved by policy" }, "suppressOutput": True } json_out(out) PROJ = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) FORBID_GLOBS = [".env", ".git/", "node_modules/"] SAFE_READ_EXT = (".md", ".mdx", ".txt", ".json", ".yaml", ".yml") # Read: auto-approve docs; ask for anything else if tool == "Read": fp = inp.get("file_path", "") if fp.endswith(SAFE_READ_EXT): allow("Documentation read auto-approved") for frag in FORBID_GLOBS: if frag in fp: deny(f"Reading '{frag}' is disallowed") ask(f"Read '{fp}'? Not in safe list.") # Write/Edit: block forbidden paths; allow otherwise if tool in ("Write", "Edit", "MultiEdit"): fp = inp.get("file_path") or inp.get("filePath") or "" for frag in FORBID_GLOBS: if frag in fp: deny(f"Writing to '{frag}' is disallowed") # Example policy: require tests when editing src/ or api/ if re.search(r"(src/|api/)", fp): ask("Code change detected; ensure tests are included per repo/testing.md") allow() # Bash: enforce safer commands if tool == "Bash": cmd = (inp.get("command") or "").strip() if not cmd: allow() if re.search(r"\bgrep\b(?!.*\|)", cmd): deny("Use 'rg' (ripgrep) instead of 'grep' for performance/features") if re.search(r"\bfind\b\s+\S+\s+-name\b", cmd): deny("Prefer 'rg --files -g' for file searches") allow() # Default: do nothing special json_out({})
Behavior:
- Uses permissionDecision =
allow | deny | ask. - Claude will automatically handle the feedback (
permissionDecisionReason).
Hook 3 — Validate results (PostToolUse)
Goal: After Write|Edit, run style/lint/tests or simple static checks. If violations exist, block the stop and feed issues back so Claude fixes them.
./.claude/hooks/posttool-validate.py:
python#!/usr/bin/env python3 import json, sys, re try: data = json.load(sys.stdin) except Exception as e: print(json.dumps({"continue": True, "systemMessage": f"Validator hook JSON error: {e}"})) sys.exit(0) tool = data.get("tool_name","") inp = data.get("tool_input",{}) or {} resp = data.get("tool_response",{}) or {} issues = [] # Example: enforce structured logging / no string concatenation logs if tool in ("Write","Edit"): content = inp.get("content") or "" if re.search(r"\bconsole\.log\(\s*`?\"?.*\+.*", content): issues.append("Use structured logging (e.g., pino) — avoid string concatenation in logs") # Require RFC7807 problem+json mention in error handlers if re.search(r"/problem\+json|RFC7807", content) is None and "error" in content.lower(): issues.append("Errors must return RFC7807 problem+json responses per standards") if issues: print(json.dumps({ "decision": "block", "reason": "Policy violations detected. Address and continue.", "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "Violations:\n- " + "\n- ".join(issues) } })) sys.exit(0) print(json.dumps({})) # no action sys.exit(0)
Behavior:
"decision": "block"feedsreasonback to Claude → the agent keeps working to fix violations.- Use this to chain lint, unit tests, type checks, etc. (or shell out from here and parse results).
Hook 4 — Don’t stop yet (Stop/SubagentStop)
Goal: If acceptance criteria aren’t met (no tests, missing API docs), block stopping and tell Claude what to do next.
./.claude/hooks/stop-enforcer.py:
python#!/usr/bin/env python3 import json, sys, re try: data = json.load(sys.stdin) except: print(json.dumps({})); sys.exit(0) # This hook is simple: if transcript indicates missing sections, ask to continue. # (For full fidelity, parse the transcript file path in input and inspect.) missing = [] # Toy illustration — in practice, parse the latest assistant message or maintain state last = "" # you can load and inspect data["transcript_path"] JSONL if desired for section in ("## Design", "## API", "## Code", "## Tests"): if section not in last: missing.append(section) if missing: print(json.dumps({ "decision": "block", "reason": f"Please complete required sections: {', '.join(missing)}" })) sys.exit(0) print(json.dumps({})) sys.exit(0)
Driving acceptance criteria
Keep ACs as versioned YAML (your example is perfect). The UserPromptSubmit hook:
- Detects the AC id from the prompt (or branch/ticket)
- Injects YAML + “Output contract” into context
- Optionally blocks if AC file is missing
Prefer short, high-signal repo/program/org summaries over full dumps; budget with simple
elide().
Security & safety
- Hooks run arbitrary commands. Quote variables (
"$CLAUDE_PROJECT_DIR"), validate inputs, and avoid sensitive paths (.env,.git/). - Use deny/ask for risky tools and paths.
- Favor JSON outputs where you need precise control and user vs. Claude routing.
- Use exit code 2 if you prefer the simple “block with stderr to Claude” path for PreToolUse.
Observability & debugging
- Add lightweight logging inside hook scripts (stdout vs. stderr as appropriate).
- Use
claude --debugand/hooksto inspect registration and execution. - Emit hashes of policy content (not full text) for auditing cost-free reproducibility.
- Common issues: quoting in JSON, non-executable scripts, wrong matchers.
Migration playbook (2 sprints)
Sprint 1
- Centralize policies under
.prompt/ - Wire
UserPromptSubmit(context) +PreToolUse(guards) - Add basic
PostToolUsechecks (logging, error handling markers)
Sprint 2
- Expand
PostToolUseto run linters/tests; parse output into violations - Add
Stopenforcer for section/AC completeness - Harden security patterns (Bash rules, forbidden paths)
- Measure: fewer clarification turns, higher first-pass AC hit rate
Opinionated defaults you can copy
Acceptance criteria contract (injected by UserPromptSubmit)
“Follow every acceptance criterion. If something is impossible, say why and propose the smallest spec change. Don’t invent APIs not in repo/ACs; propose in Design first.”
Output sections (Stop-enforced)
- Design (trade-offs, alternatives, migration)
- API (routes, shapes, errors with RFC7807)
- Code (fenced blocks; deterministic examples)
- Tests (golden + failure; how to run)
Why this works
- Determinism: Hooks execute on known events with explicit allow/deny/ask decisions.
- Coverage: You can intercept prompts, tool calls, results, and stops.
- Auditability: Standards live in the repo; hooks reference files by path; changes are reviewed in PRs.
- Low friction: A few short scripts; no SDK changes.
Adopt UserPromptSubmit + PreToolUse + PostToolUse first; add Stop when you want strict Definition-of-Done gating. Your agents will “keep the rules in their heads” automatically.
