-
Notifications
You must be signed in to change notification settings - Fork 8
Hooks Guide
Text instructions do not work. CLAUDE.md files, plugin hooks that inject reminders, system prompts that say "you MUST delegate" — LLMs treat all of them as suggestions. In practice, Claude reads the instruction, understands it, and then writes UI code without delegating to the accessibility agents. The user has to manually ask "did you do the accessibility review?" every single time.
This happened consistently across projects. The agents were available. The instructions were clear. Claude just ignored them.
Instead of telling Claude to use the accessibility agents, we make it impossible to skip them. Three hooks work together as a gate:
User prompt
|
v
[1. UserPromptSubmit] — Detects web project, tells Claude to delegate
|
v
Claude tries to Edit/Write a .tsx file
|
v
[2. PreToolUse] — Checks for session marker. No marker? DENIED.
| Claude cannot write to the file.
|
v
Claude delegates to accessibility-lead (because it has no other choice)
|
v
[3. PostToolUse] — accessibility-lead completes. Marker created.
|
v
Claude retries Edit/Write — marker exists — ALLOWED.
Claude does not use the accessibility agents because it was told to. It uses them because it literally cannot edit UI files without doing so first.
File: ~/.claude/hooks/a11y-team-eval.sh
Event: UserPromptSubmit
Purpose: Detect web projects and inject the delegation instruction.
This hook runs on every user prompt. It has two detection modes:
Before reading the user's prompt, the hook checks the current working directory for web project indicators:
-
package.jsoncontaining React, Next.js, Vue, Svelte, Astro, Angular, Tailwind, or other web framework dependencies - Config files like
next.config.js,vite.config.ts,tailwind.config.js,angular.json - Files with UI extensions (
.jsx,.tsx,.vue,.svelte,.astro) within three directory levels - Server-side template files (
.html,.ejs,.hbs,.leaf,.erb,.jinja,.twig,.blade.php)
If any indicator is found, the hook fires on every prompt regardless of what the user typed. A prompt like "fix the bug" in a Next.js project triggers the instruction.
For directories that are not web projects, the hook falls back to keyword matching. If the user's prompt contains UI-related terms (component, modal, button, voiceover, accessibility, tailwind, etc.), the hook fires.
When triggered, the hook outputs the delegation instruction as a system reminder. Claude sees it before processing the prompt.
File: ~/.claude/hooks/a11y-enforce-edit.sh
Event: PreToolUse (matcher: Edit|Write)
Purpose: Block writes to UI files until accessibility review is complete.
This is the enforcement hook. When Claude attempts to Edit or Write a file, this hook:
- Extracts the
file_pathfrom the tool input JSON - Checks if the file is a UI file based on extension (
.jsx,.tsx,.vue,.svelte,.astro,.html,.css,.scss,.ejs,.hbs,.leaf,.erb,.jinja,.twig,.blade.php) or path (files incomponents/,pages/,views/,layouts/,templates/) - If it is a UI file, checks for the session marker at
/tmp/a11y-reviewed-{session_id} - If the marker exists, allows the edit silently
- If the marker does not exist, denies the tool call with a clear reason
The denial uses the permissionDecision: "deny" mechanism:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED: Cannot edit UI file 'user-menu.tsx' without accessibility review..."
}
}This is not a reminder or suggestion. The Edit/Write tool call is rejected at the hook level. Claude receives the reason as feedback and must delegate to the accessibility-lead before retrying.
Non-UI files (.ts in lib/, .json, .md, backend code) are always allowed without review.
File: ~/.claude/hooks/a11y-mark-reviewed.sh
Event: PostToolUse (matcher: Agent)
Purpose: Create the session marker when accessibility-lead completes.
After any Agent tool call completes, this hook checks the subagent_type in the tool input. If it contains accessibility-lead, the hook creates a marker file:
/tmp/a11y-reviewed-{session_id}
The session ID comes from the hook's stdin JSON, which includes it automatically. The marker is session-specific — each new Claude Code session requires a fresh accessibility review.
Once the marker exists, Hook 2 allows all UI file edits for the rest of the session.
All three hooks are registered in ~/.claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/Users/you/.claude/hooks/a11y-team-eval.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "/Users/you/.claude/hooks/a11y-enforce-edit.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "/Users/you/.claude/hooks/a11y-mark-reviewed.sh"
}
]
}
]
}
}The installer creates these entries automatically during --global installation.
An MCP server was considered as an alternative. MCP servers provide tools that Claude can call, which might seem more reliable than hooks. But the fundamental problem is the same: Claude has to choose to call the tool. An MCP server would add accessibility review as an available tool, but Claude would still skip it just like it skips text instructions.
Hooks solve this because they operate at the infrastructure level. The PreToolUse hook does not ask Claude to do anything. It blocks the Edit/Write tool call at the system level. Claude has no option to skip it.
The plugin system supports hooks.json inside the plugin package. These hooks fire on every prompt. The problem: plugin hooks can only inject text instructions (system reminders). They cannot block tool calls.
The project originally used plugin hooks to inject the delegation instruction on every prompt. Claude received the instruction and still ignored it. The instruction said "MANDATORY" and "NON-OPTIONAL" and "Do NOT skip this step." Claude skipped it anyway.
Plugin hooks remain available as a fallback reminder layer, but enforcement is handled entirely by the global hooks registered in ~/.claude/settings.json.
Verify the hook is registered:
cat ~/.claude/settings.json | python3 -m json.toolCheck that the hook script exists and is executable:
ls -la ~/.claude/hooks/a11y-enforce-edit.shCheck that the session marker was created:
ls /tmp/a11y-reviewed-*If no marker exists, the PostToolUse hook may not have fired. Verify the Agent matcher is registered in settings.json under PostToolUse.
The hook checks file extensions and directory paths. If a .ts file in lib/ is being blocked, the path-based check may be matching too broadly. Files in components/, pages/, views/, layouts/, and templates/ directories are treated as UI files even with .ts/.js extensions. Move non-UI code out of these directories or adjust the hook.
You can test each hook by piping JSON to stdin:
# Test PreToolUse — should output deny JSON
echo '{"tool_name":"Edit","tool_input":{"file_path":"/path/to/component.tsx"},"session_id":"test-123"}' | bash ~/.claude/hooks/a11y-enforce-edit.sh
# Create marker
echo '{"tool_name":"Agent","tool_input":{"subagent_type":"accessibility-agents:accessibility-lead"},"session_id":"test-123"}' | bash ~/.claude/hooks/a11y-mark-reviewed.sh
# Test PreToolUse again — should output nothing (allowed)
echo '{"tool_name":"Edit","tool_input":{"file_path":"/path/to/component.tsx"},"session_id":"test-123"}' | bash ~/.claude/hooks/a11y-enforce-edit.sh
# Clean up
rm /tmp/a11y-reviewed-test-123- Accessibility Lead
- Web Accessibility Wizard
- Document Accessibility Wizard
- Alt Text and Headings
- ARIA Specialist
- Contrast Master
- Forms Specialist
- Keyboard Navigator
- Link Checker
- Live Region Controller
- Modal Specialist
- Tables Data Specialist
- Word Accessibility
- Excel Accessibility
- PowerPoint Accessibility
- PDF Accessibility
- Office Scan Config
- PDF Scan Config
- Testing Coach
- WCAG Guide