Files
singular-particular-space/skills/commissioning-skill/hook-protocol.md
JL Kruger 5422131782 Initial commit — Singular Particular Space v1
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>
2026-03-27 12:09:22 +02:00

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

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