# 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.
