Homepage (site/index.html): integration-v14 promoted, Writings section integrated with 33 pieces clustered by type (stories/essays/miscellany), Writings welcome lightbox, content frame at 98% opacity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
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:
{"decision": "block", "reason": "Credential file write blocked by spore policy"}
{"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):
{
"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,contentBash:commandRead:file_pathWebFetch: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.
#!/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.
#!/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.
#!/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.
#!/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.
{
"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:
# 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.