# Hook protocol Claude Code hooks are executables that intercept agent lifecycle events. They require no SDK or external library — the protocol is just stdin JSON in, exit code out. This file contains everything needed to generate correct hooks in Python (stdlib only) or JavaScript (Node.js, no packages). --- ## The protocol Claude Code spawns the hook executable, writes a JSON event to its stdin, and reads the exit code and stdout when it exits. **Exit codes:** - `0` — allow / success. Claude Code continues. Any stdout is added to context. - `1` — non-blocking error. Claude Code sees the message and continues anyway. - `2` — blocking deny. Claude Code stops the action and shows the reason. **JSON control output** (optional, write to stdout as valid JSON): For `PreToolUse` hooks, you can output a JSON object instead of relying solely on the exit code. This gives you a reason string that Claude Code surfaces to the agent: ```json {"decision": "block", "reason": "Credential file write blocked by spore policy"} ``` ```json {"decision": "approve"} ``` For `PostToolUse` hooks, stdout text (not JSON) is injected into the agent's context as tool result feedback. **Input event shape** (arrives on stdin as a single JSON line): ```json { "tool_name": "Write", "tool_input": { "file_path": "path/to/file", "content": "file content" }, "tool_response": "...", "session_id": "abc123" } ``` `tool_response` is only present on `PostToolUse` events. `tool_input` shape varies by tool — see field names below. **Common tool_input fields by tool:** - `Write` / `Edit` / `MultiEdit`: `file_path`, `content` - `Bash`: `command` - `Read`: `file_path` - `WebFetch`: `url` --- ## Python template (stdlib only) Use this for projects that have Python available. Zero dependencies — no pip install, no virtualenv. Copy, rename, fill in the guard logic. ```python #!/usr/bin/env python3 # hooks/{hook-name}.py # Spore: {spore-identifier} # {one-line description of what this hook guards} # # Protocol: reads JSON event from stdin, exits 0 (allow) or 2 (deny). # No external dependencies — stdlib only. import json import sys def main() -> None: # Read the full event from stdin try: event = json.loads(sys.stdin.read()) except (json.JSONDecodeError, OSError): # If we can't read the event, allow and let the agent handle it sys.exit(0) tool_name = event.get("tool_name", "") tool_input = event.get("tool_input", {}) # --- Guard logic starts here --- # Replace this block with the compiled spore decision. # Example: block writes to credential files. if tool_name == "Write": file_path = tool_input.get("file_path", "") BLOCKED = {".env", "secrets.json", "id_rsa", ".pem", ".key"} if any(pattern in file_path for pattern in BLOCKED): # Write structured denial to stdout for Claude Code to surface print(json.dumps({ "decision": "block", "reason": f"Credential file protected by spore policy: {file_path}" })) sys.exit(2) # --- Guard logic ends here --- # Default: allow sys.exit(0) if __name__ == "__main__": main() ``` --- ## JavaScript template (Node.js, no packages) Use this for TypeScript/JavaScript projects, or any project where Node.js is available (Claude Code itself runs on Node so it is always present). Zero dependencies — pure Node.js stdlib. ```javascript #!/usr/bin/env node // hooks/{hook-name}.js // Spore: {spore-identifier} // {one-line description of what this hook guards} // // Protocol: reads JSON event from stdin, exits 0 (allow) or 2 (deny). // No external dependencies — Node.js stdlib only. async function main() { // Read the full event from stdin let raw = ""; for await (const chunk of process.stdin) { raw += chunk; } let event; try { event = JSON.parse(raw); } catch { // If we can't parse the event, allow and let the agent handle it process.exit(0); } const toolName = event.tool_name ?? ""; const toolInput = event.tool_input ?? {}; // --- Guard logic starts here --- // Replace this block with the compiled spore decision. // Example: block writes to credential files. if (toolName === "Write") { const filePath = toolInput.file_path ?? ""; const blocked = [".env", "secrets.json", "id_rsa", ".pem", ".key"]; if (blocked.some((pattern) => filePath.includes(pattern))) { // Write structured denial to stdout for Claude Code to surface process.stdout.write(JSON.stringify({ decision: "block", reason: `Credential file protected by spore policy: ${filePath}`, })); process.exit(2); } } // --- Guard logic ends here --- // Default: allow process.exit(0); } main().catch(() => process.exit(0)); // On unexpected error, fail open ``` --- ## PostToolUse validator template (Python) PostToolUse hooks receive the completed tool result and can inject feedback into the agent's context. Use for output format validation, quality checks, or logging. ```python #!/usr/bin/env python3 # hooks/{hook-name}-post.py # Spore: {spore-identifier} # Validates output after a tool completes. Injects feedback into agent context. import json import sys def main() -> None: try: event = json.loads(sys.stdin.read()) except (json.JSONDecodeError, OSError): sys.exit(0) tool_name = event.get("tool_name", "") tool_input = event.get("tool_input", {}) # tool_response contains what the tool returned tool_response = event.get("tool_response", "") # --- Validation logic starts here --- # Example: verify annotation output is valid JSONL if tool_name == "Write": file_path = tool_input.get("file_path", "") if file_path.endswith(".annotation"): content = tool_input.get("content", "") invalid = [] for i, line in enumerate(content.strip().splitlines(), 1): if line.strip(): try: json.loads(line) except json.JSONDecodeError: invalid.append(i) if invalid: # Exit 2 to block; message shown to agent print(json.dumps({ "decision": "block", "reason": f"Output format violation: lines {invalid} are not valid JSON. " f"Annotation output must be JSONL (one JSON object per line)." })) sys.exit(2) # --- Validation logic ends here --- # Exit 0; any stdout text gets injected into agent context as feedback sys.exit(0) if __name__ == "__main__": main() ``` --- ## SessionStart context loader template (Python) Loads the spore file into session context on startup so the scion has its policy available from the first turn. ```python #!/usr/bin/env python3 # hooks/session-start.py # Loads SCION_SPORE_FILE contents into session context on startup. # The commissioning skill sets SCION_SPORE_FILE at spawn time. import json import os import sys def main() -> None: try: event = json.loads(sys.stdin.read()) except (json.JSONDecodeError, OSError): sys.exit(0) # Only act on startup, not resume or clear if event.get("source") != "startup": sys.exit(0) spore_file = os.environ.get("SCION_SPORE_FILE", "") if not spore_file or not os.path.exists(spore_file): # No spore file configured — allow and continue sys.exit(0) try: with open(spore_file) as f: content = f.read() # Print to stdout — SessionStart hook output is added to session context print(f"Spore policy active ({spore_file}):\n\n{content}") except OSError: pass # Fail open — don't block startup if spore file unreadable sys.exit(0) if __name__ == "__main__": main() ``` --- ## Registration in .claude/settings.json Register hooks at the module level (`.claude/settings.json` in the module folder) rather than globally. This keeps hook scope aligned with spore scope. ```json { "hooks": { "PreToolUse": [ { "matcher": "Write", "hooks": [ { "type": "command", "command": "python hooks/env-guard.py" } ] }, { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python hooks/bash-guard.py" } ] } ], "PostToolUse": [ { "matcher": "Write", "hooks": [ { "type": "command", "command": "python hooks/output-validator-post.py" } ] } ], "SessionStart": [ { "hooks": [ { "type": "command", "command": "python hooks/session-start.py" } ] } ] } } ``` For Node.js hooks, replace `python hooks/name.py` with `node hooks/name.js`. For executable hooks (chmod +x), the command can be the file path directly. --- ## Choosing Python vs JavaScript Use Python when the project uses Python or when no preference is stated — Python stdlib is universally available and the syntax reads clearly to most agents. Use JavaScript (Node.js) when the project is a TypeScript/JavaScript codebase, or when the hook needs to share logic with existing project tooling. The bartolli pattern (`.claude/hooks/{project-type}/quality-check.js`) is worth adopting when multiple hook types share configuration — see that repo for a mature example of project-type-aware Node.js hooks with SHA256 config caching for performance. Either language produces identical protocol behavior. The hook protocol does not care what generates the exit code. --- ## Validation before registration Every generated hook must pass two test cases before the commissioning skill registers it: one that should trigger the deny condition, and one that should allow cleanly. Run manually: ```bash # Should exit 2 (deny) — triggers the guard echo '{"tool_name":"Write","tool_input":{"file_path":".env","content":"SECRET=x"}}' \ | python hooks/env-guard.py echo "Exit code: $?" # Should exit 0 (allow) — safe path echo '{"tool_name":"Write","tool_input":{"file_path":"output.txt","content":"hello"}}' \ | python hooks/env-guard.py echo "Exit code: $?" ``` If either test fails, do not register the hook or reference it in a spore entry. Fix first, validate again, then deposit.