Hooks
What metraton-gaia runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add metraton/gaia > /plugin install gaia@gaia-marketplace
Ships with metraton-gaia. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
- Matches
Bashpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py - Matches
Taskpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py - Matches
Agentpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py - Matches
SendMessagepython3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py - Matches
Read|Edit|Write|Glob|Grep|WebSearch|WebFetch|NotebookEditpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py
PostToolUse
- Matches
Bashpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py - Matches
Taskpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py - Matches
AskUserQuestionpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py
SubagentStop
- Matches
*python3 ${CLAUDE_PLUGIN_ROOT}/hooks/subagent_stop.py
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
startup|resume|compactpython3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py
SessionEnd
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_end_hook.py
PreCompact
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pre_compact.py
PostCompact
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/post_compact.py
Stop
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop_hook.py
TaskCompleted
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/task_completed.py
SubagentStart
- Matches
*python3 ${CLAUDE_PLUGIN_ROOT}/hooks/subagent_start.py
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py
Where it lives
- hooks/post_compact.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """PostCompact hook — logging stub; real re-injection happens elsewhere. PLATFORM LIMITATION: Claude Code's hook-output schema does not accept ``hookSpecificOutput.hookEventName == "PostCompact"`` (same discriminated union as PreCompact -- see pre_compact.py's docstring for the full list of accepted values), and the runtime's response-consumption switch has no ``"PostCompact"`` case either, so ``additionalContext`` is unreachable for this event even when the JSON is otherwise well-formed. The previous version of this hook built the compact-context refresh (agent roster + active anomalies) and shipped it under this unsupported shape, so every ``/compact`` failed Claude Code's JSON validation with "(root): Invalid input" and the refresh was silently dropped -- never delivered. The real, valid delivery mechanism is ``SessionStart`` with ``source == "compact"``: Claude Code's SessionStart matcherMetadata lists ``compact`` as one of its accepted `source` values (alongside `startup`, `resume`, `clear`, `fork`), and SessionStart's `hookSpecificOutput` DOES support `additionalContext`. ``hooks/session_start.py`` is now wired for ``startup|resume|compact`` and builds the SAME compact-context refresh (via ``modules.context.compact_context_builder.build_compact_context``) when it fires with ``source == "compact"``. This file stays registered for the ``PostCompact`` event as a harmless, schema-valid no-op (parallel to pre_compact.py) purely for observability -- it no longer calls ``build_compact_context()`` itself, to avoid a second, discarded read of the same DB queries on every compaction. """ import sys import json import logging from pathlib import Path _hooks_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_hooks_dir)) _pkg_root = str(_hooks_dir.parent) if _pkg_root not in sys.path: sys.path.insert(0, _pkg_root) from modules.core.hook_entry import run_hook from modules.core.logging_setup import configure_hook_logging # Configure logging -- file handler only when GAIA_DEBUG is set; no # hooks-*.log is written by default (see modules.core.logging_setup). configure_hook_logging("post_compact") logger = logging.getLogger(__name__) def _handle_post_compact(event) -> None: """Log that compaction finished; the real refresh fires via SessionStart.""" logger.info( "PostCompact fired (event has no additionalContext support in " "Claude Code); compact-context refresh is delivered via " "SessionStart(source=compact) instead -- see session_start.py" ) # No hookSpecificOutput: PostCompact does not accept one. An empty # object is the schema-valid "nothing to report" response. print(json.dumps({})) sys.exit(0) if __name__ == "__main__": run_hook(_handle_post_compact, hook_name="post_compact") - hooks/post_tool_use.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """ Post-tool use hook - Thin gate. Architecture: - Uses adapter layer to parse and process the full PostToolUse lifecycle - All business logic lives in ClaudeCodeAdapter.adapt_post_tool_use() - This file is stdin/stdout glue only """ import sys import json import logging from pathlib import Path _hooks_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_hooks_dir)) _pkg_root = str(_hooks_dir.parent) if _pkg_root not in sys.path: sys.path.insert(0, _pkg_root) from modules.core.logging_setup import configure_hook_logging from adapters.registry import get_adapter from modules.core.hook_entry import run_hook # Configure logging -- file handler only when GAIA_DEBUG is set; no # hooks-*.log is written by default (see modules.core.logging_setup). configure_hook_logging("post_tool_use") logger = logging.getLogger(__name__) def _handle_post_tool_use(event) -> None: """Process a PostToolUse event. Delegates all business logic to the adapter. Args: event: Parsed HookEvent from the adapter layer. """ adapter = get_adapter() response = adapter.adapt_post_tool_use(event) if response.output: print(json.dumps(response.output)) sys.exit(response.exit_code) # ============================================================================ # STDIN HANDLER (Claude Code integration) # ============================================================================ if __name__ == "__main__": run_hook(_handle_post_tool_use, hook_name="post_tool_use") - hooks/pre_compact.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """PreCompact hook — a schema-valid no-op registered for the PreCompact event. The event carries no deliverable payload: Claude Code neither validates nor consumes ``hookSpecificOutput`` for PreCompact (see ``_handle_pre_compact``), so there is nothing this hook can inject into the model's context. It stays registered so the event has a well-formed responder that can never block compaction, and so a future capability has a wired entry point. All errors are caught — this hook never blocks compaction. """ import sys import json import logging from pathlib import Path _hooks_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_hooks_dir)) _pkg_root = str(_hooks_dir.parent) if _pkg_root not in sys.path: sys.path.insert(0, _pkg_root) from modules.core.hook_entry import run_hook from modules.core.logging_setup import configure_hook_logging # Configure logging -- file handler only when GAIA_DEBUG is set; no # hooks-*.log is written by default (see modules.core.logging_setup). configure_hook_logging("pre_compact") logger = logging.getLogger(__name__) def _handle_pre_compact(event) -> None: """Emit the schema-valid empty response for PreCompact. PLATFORM LIMITATION: Claude Code's hook-output schema does not accept ``hookSpecificOutput.hookEventName == "PreCompact"`` -- the validated discriminated union only covers PreToolUse, UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, SessionStart, Setup, SubagentStart, PermissionDenied, PermissionRequest, Elicitation, ElicitationResult, and MessageDisplay -- and even a passing shape would go nowhere: the runtime's response-consumption switch (which maps ``hookSpecificOutput.hookEventName`` to an applied effect) has no ``"PreCompact"`` case at all, so `additionalContext` is unreachable for this event regardless of schema validity. Emitting the previous shape made every ``/compact`` fail Claude Code's JSON validation with "(root): Invalid input". There is currently no hook event that can inject model context in the narrow window *before* compaction erases it; the post-compaction refresh happens instead at SessionStart with ``source == "compact"``. So this handler only logs for GAIA_DEBUG diagnosis and returns a schema-valid empty response. """ logger.info("PreCompact: no deliverable payload for this event, returning {}") # No hookSpecificOutput: PreCompact does not accept one. An empty object # is the schema-valid "nothing to report" response for every hook event. print(json.dumps({})) sys.exit(0) if __name__ == "__main__": run_hook(_handle_pre_compact, hook_name="pre_compact") - hooks/pre_tool_use.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """ Pre-tool use hook - Thin Gate Architecture. Entry point for Bash and Task/Agent tool validation. The hook is the primary security gate: with Bash(*) in the settings.json allow list, all commands reach this hook regardless of settings.json permissions. Architecture: - Uses adapter layer to parse and process the full PreToolUse lifecycle - All business logic lives in ClaudeCodeAdapter.adapt_pre_tool_use() - This file is stdin/stdout glue only Two obligations belong to the glue itself, because nothing downstream can discharge them. First, it WAITS for the payload rather than sampling for it: the decision to run at all is made here, and a gate that skips itself because the host's write was a few milliseconds late is the one failure no verdict can describe. Second, EVERY exit records -- an exit that delivers no verdict is a gate failure and leaves a fail-open trace, so no path out of this file is mute. The former backward-compatible API (pre_tool_use_hook / _handle_* / main) was retired: it diverged from the real path (no delegate-mode gate, no CLI-only guard, no identity injection, no born-at-dispatch row) and its module-level imports made every hook invocation pay for a lane only tests used. Tests drive adapters.claude_code.ClaudeCodeAdapter.adapt_pre_tool_use directly (see tests/fixtures/pretool_adapter.py). """ from __future__ import annotations import os import select import sys import json import logging from pathlib import Path from typing import Any, Mapping, NoReturn, Optional _hooks_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_hooks_dir)) _pkg_root = str(_hooks_dir.parent) if _pkg_root not in sys.path: sys.path.insert(0, _pkg_root) from modules.core.hook_trace import record_hook_invocation from modules.core.logging_setup import configure_hook_logging # Adapter layer -- get_adapter() is the single construction point (registry), # so this entry point never names the concrete host class. from adapters.registry import get_adapter from modules.core.stdin import has_stdin_data from adapters.utils import warn_if_dual_channel # Configure logging -- file handler only when GAIA_DEBUG is set (see # modules.core.logging_setup); no hooks-*.log is written by default. configure_hook_logging("pre_tool_use") logger = logging.getLogger(__name__) USAGE = ( "Usage: echo '{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\"," "\"tool_input\":{\"command\":\"ls\"}}' | python pre_tool_use.py" ) # How long to wait for the host's payload before concluding there is none. # # The check this replaces was a zero-wait select(): if the write had not # physically landed in the pipe at that instant, the gate declared it had no # work and exited, and the tool call ran unvalidated. Nothing orders the host's # write against this process's first read -- the race was only ever won by # accident, because interpreter startup usually costs more than the write. # # Waiting is the remedy that fits the shape of the problem: the payload is # almost always already buffered, so the wait costs nothing in the normal case # and is paid only when the gate would otherwise have skipped itself. The # deadline is what keeps the remedy from being worse than the defect -- a gate # that blocked forever on a pipe nobody writes to would stop the session rather # than the command. Overridable so an install on a slow host can widen it and # tests can shrink it. _STDIN_WAIT_SECONDS_DEFAULT = 2.0 _STDIN_WAIT_ENV = "GAIA_HOOK_STDIN_TIMEOUT" def _stdin_wait_seconds() -> float: """Deadline for the payload to arrive, in seconds.""" raw = os.environ.get(_STDIN_WAIT_ENV, "").strip() if raw: try: parsed = float(raw) if parsed >= 0: return parsed except ValueError: pass return _STDIN_WAIT_SECONDS_DEFAULT def _await_stdin_data(timeout: float) -> bool: """Whether the host's payload is readable, waiting up to ``timeout`` for it. A closed pipe counts as readable and resolves immediately: that is an EOF, an answer rather than a wait. An interactive stdin is not a hook invocation at all and never waits. """ if sys.stdin.isatty(): return False try: readable, _, _ = select.select([sys.stdin], [], [], max(timeout, 0.0)) return bool(readable) except Exception: return has_stdin_data() def _trace( exit_code: int, *, payload: Optional[Mapping[str, Any]] = None, blocked: bool = False, extra: Optional[Mapping[str, Any]] = None, ) -> None: """Record that this hook ran, whatever it decided or failed to decide. Every exit records, including the ones that decide nothing: an invocation that leaves no line is indistinguishable offline from a hook that was never dispatched, which is precisely the confusion the silent exits caused. """ record_hook_invocation( "pre_tool_use", payload=payload, exit_code=exit_code, blocked=blocked, extra=dict(extra) if extra else None, ) def _fail_open_exit( reason: str, detail: str, *, payload: Optional[Mapping[str, Any]] = None, cause: Optional[str] = None, ) -> NoReturn: """Deliver a gate failure to the user and the record, then exit. Every exit of this entry point that is not a delivered verdict is a gate failure: the hook returns without having decided. Routing them all through here is what keeps that from being silent, and what applies the one case where the operation is stopped instead -- a command the gate had already classified as mutating. See modules.security.fail_open. Imported lazily so an ordinary invocation, which never reaches this path, does not pay for the import. """ from modules.security.fail_open import CAUSE_ERROR, decide_fail_open outcome = decide_fail_open(reason, detail, cause or CAUSE_ERROR) print(outcome.message, file=sys.stderr) prin - hooks/session_end_hook.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """ SessionEnd hook for Claude Code Agent System. Fires when a Claude Code session terminates. Unregisters the session from the user-scoped session registry so that T12/T13 liveness filters stop considering it live. Architecture: - Reads SessionEnd event via the shared run_hook() entrypoint - Reads CLAUDE_SESSION_ID from environment - Calls session_registry.unregister_session() guarded by SessionRegistryError - Failures are non-fatal: a missing registry entry must never block shutdown - Returns an empty JSON response and exits 0 """ import os import sys import json import logging from pathlib import Path _hooks_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_hooks_dir)) _pkg_root = str(_hooks_dir.parent) if _pkg_root not in sys.path: sys.path.insert(0, _pkg_root) from modules.core.hook_entry import run_hook from modules.core.logging_setup import configure_hook_logging from modules.session.session_registry import unregister_session, SessionRegistryError # Configure logging -- file handler only when GAIA_DEBUG is set; no # hooks-*.log is written by default (see modules.core.logging_setup). configure_hook_logging("session_end") logger = logging.getLogger(__name__) def _handle_session_end(event) -> None: """Process a SessionEnd event. Unregisters the session from the session registry. Non-fatal: "session not found" is already a silent no-op inside the registry; SessionRegistryError here only signals I/O failure, which is expected in shutdown race conditions. Args: event: Parsed HookEvent from the adapter layer. """ try: _sid = os.environ.get("CLAUDE_SESSION_ID") if _sid: unregister_session(session_id=_sid) logger.info("SessionEnd: unregistered session %s", _sid) except SessionRegistryError as _reg_exc: logger.debug("session_registry unregister failed (non-fatal): %s", _reg_exc) print(json.dumps({})) sys.exit(0) # ============================================================================ # STDIN HANDLER (Claude Code integration) # ============================================================================ def main() -> None: """Module-level entrypoint used by tests and by the ``__main__`` block. Delegates to ``run_hook()`` exactly like the inline ``__main__`` body would, but via a named function so tests can import this module and invoke the handler without spawning a subprocess. """ run_hook(_handle_session_end, hook_name="session_end_hook") if __name__ == "__main__": main() - hooks/session_start.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """SessionStart hook — first-time setup + context injection (no auto-scan).""" import os import sys import json import logging from pathlib import Path from typing import Optional _hooks_dir = Path(__file__).resolve().parent sys.path.insert(0, str(_hooks_dir)) _pkg_root = str(_hooks_dir.parent) if _pkg_root not in sys.path: sys.path.insert(0, _pkg_root) from modules.core.workspace_bootstrap import ensure_workspace_hooks_link ensure_workspace_hooks_link() # --------------------------------------------------------------------------- # Headless detection # --------------------------------------------------------------------------- def _detect_headless(proc_root: Optional[Path] = None) -> bool: """Best-effort detection of headless / non-interactive sessions. Returns True when this Claude Code session is running without an interactive TUI. Sources, in order of confidence: 1. Explicit env: CLAUDE_HEADLESS=1, CI=true, NONINTERACTIVE=1. These are the most reliable signals and the only ones the user can opt into deliberately. 2. SDK CLI invocation: the parent process is `claude` invoked with a print/output flag (`-p`, `--print`, `--output-format json`). The SDK CLI does NOT set CLAUDE_HEADLESS, so without this fallback every `claude -p ...` call would register as interactive and pollute liveness tracking. 3. Stdout is not a TTY. This is the weakest signal -- pipes happen in interactive sessions too -- so it is only used as a tertiary tiebreaker, never as a primary trigger. The /proc/<pid>/cmdline read is Linux-only. On other platforms the function silently falls through to the TTY check. Any unexpected error in the parent-cmdline probe is swallowed -- this hook must never block session start. Args: proc_root: Override for /proc (test injection). Defaults to /proc. """ # (1) Explicit env signals. if os.environ.get("CLAUDE_HEADLESS") == "1": return True if os.environ.get("CI", "").lower() == "true": return True if os.environ.get("NONINTERACTIVE") == "1": return True # (2) Parent-process probe for SDK CLI invocations. if proc_root is None: proc_root = Path("/proc") try: if proc_root.exists(): ppid = os.getppid() cmdline_path = proc_root / str(ppid) / "cmdline" if cmdline_path.exists(): # /proc/<pid>/cmdline is NUL-separated, with a trailing NUL. raw = cmdline_path.read_bytes().decode("utf-8", errors="replace") argv = [a for a in raw.split("\x00") if a] if argv: exe = Path(argv[0]).name.lower() # Match the claude SDK CLI -- not the interactive TUI. # Interactive `claude` has no -p/--print flag. if "claude" in exe: for arg in argv[1:]: if arg in ("-p", "--print"): return True if arg.startswith("--output-format"): return True except (OSError, ValueError, UnicodeDecodeError): # /proc missing (non-Linux), cmdline gone (race), or unparseable. # All non-fatal: fall through to TTY check. pass # (3) Tertiary: stdout not a TTY. Weak signal -- only return True if # explicitly non-tty AND the process likely lacks a controlling # terminal. We do NOT use this alone because piping stdout in an # interactive session is common. try: if not sys.stdout.isatty() and not sys.stdin.isatty(): # Both pipes closed: very likely a headless invocation. return True except (AttributeError, ValueError): pass return False from modules.core.stdin import has_stdin_data from modules.core.logging_setup import configure_hook_logging from modules.core.plugin_setup import run_first_time_setup from modules.session.session_registry import register_session, SessionRegistryError # Configure logging -- file handler only when GAIA_DEBUG is set; no # hooks-*.log is written by default (see modules.core.logging_setup). configure_hook_logging("session_start") logger = logging.getLogger(__name__) if __name__ == "__main__": if not has_stdin_data(): sys.exit(0) try: # Parse the stdin event so we can recover session_id from it. # Claude Code always includes session_id in the JSON event piped # to the hook; CLAUDE_SESSION_ID is *not* guaranteed in the hook # subprocess env. Reading from the event is the reliable source. _raw_stdin = sys.stdin.read() try: event_data = json.loads(_raw_stdin) if _raw_stdin else {} if not isinstance(event_data, dict): event_data = {} except (json.JSONDecodeError, TypeError): event_data = {} from modules.core.state import resolve_session_id _sid = resolve_session_id(event_data) # Pin the build this session is actually running. The RUNNING # session_start hook IS the code Claude Code loaded for this session, # so ``__file__``'s resolved parent is the authoritative running-hooks # tree -- more precise than re-resolving the ``.claude/hooks`` symlink # (which could already point elsewhere if a repack raced this hook). # We snapshot its content digest so `gaia doctor` can later tell the # user whether the wired hooks still match what is running (ACTIVE) or # a `gaia dev` landed a newer build that needs a restart (STALE). # Fully best-effort: any failure leaves the marker absent (doctor # reports UNKNOWN), it never blocks session start. _pinned_build = None try: from gaia.hooks_build import hooks_content_hash _running_h - hooks/stop_hook.pyRunsGitHub
- hooks/subagent_start.pyRunsGitHub
- hooks/subagent_stop.pyRunsGitHub
- hooks/task_completed.pyRunsGitHub
- hooks/user_prompt_submit.pyRunsGitHub
All 11 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Repo: metraton/gaia

