Skip to content
Development
Hook

Hooks

What gaia runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
gaia
443 skills9 agents12 hooks

Claude Code

> /plugin marketplace add metraton/gaia
> /plugin install gaia@gaia-marketplace

What fires, and when

PreToolUse

  • MatchesTasksh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
  • MatchesAgentsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
  • MatchesSendMessagesh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
  • MatchesAskUserQuestionsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
  • MatchesRead|Edit|Write|Glob|Grep|WebSearch|WebFetch|NotebookEditsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
  • Matches^(?!Agent$)(?!Artifact$)(?!AskUserQuestion$)(?!CronCreate$)(?!Crsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"

PostToolUse

  • MatchesBash|PowerShell|Monitorsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
  • MatchesTasksh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
  • MatchesAskUserQuestionsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
  • MatchesSkillsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"

PostToolUseFailure

  • MatchesBash|PowerShell|Monitorsh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"

SubagentStop

  • Matches*sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${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.

  • Matchesstartup|resume|clear|compact|forksh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py"

SessionEnd

  • sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/session_end_hook.py"

PreCompact

  • sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/pre_compact.py"

PostCompact

  • sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/post_compact.py"

Stop

  • sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/stop_hook.py"

TaskCompleted

  • sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/task_completed.py"

SubagentStart

  • Matches*sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${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.

  • sh "${CLAUDE_PLUGIN_ROOT}/hooks/launch.sh" "${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py"
Read hooks/hooks.json

Where it lives

  • hooks/launch.shRunsGitHub
    Read the script
    #!/bin/sh
    # Runs a Gaia hook entrypoint ("$@") with the first working Python 3.
    # The python.org Windows installer provides `python` and `py`, not `python3`,
    # and Windows' App Execution Alias answers `python3` without being Python --
    # so each candidate is probed before it is trusted.
    # UTF-8 mode, inherited by every child Python: the locale codec (cp1252 on
    # Windows) would mangle non-ASCII payloads, paths and git output.
    export PYTHONUTF8=1
    for candidate in python3 python; do
      if command -v "$candidate" >/dev/null 2>&1 &&
         "$candidate" -c 'import sys; sys.exit(sys.version_info[0] != 3)' >/dev/null 2>&1; then
        exec "$candidate" "$@"
      fi
    done
    if command -v py >/dev/null 2>&1; then
      exec py -3 "$@"
    fi
    echo "gaia: no Python 3 found on PATH (tried python3, python, py -3)" >&2
    exit 1
    
  • hooks/post_compact.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """PostCompact hook — closes the session's compaction bracket; re-injection happens elsewhere.
    
    The event input carries the ``compact_summary`` the compaction produced, which
    this hook does not read. Claude Code does not accept ``hookSpecificOutput`` for
    PostCompact (see pre_compact.py), so ``additionalContext`` cannot reach the
    model from this event and the hook answers ``{}``.
    
    The refresh is delivered by ``hooks/session_start.py``, which hooks.json wires
    for ``startup|resume|clear|compact|fork`` and which injects context for every
    one of them.
    """
    
    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_hook_logging("post_compact")
    logger = logging.getLogger(__name__)
    
    
    def _handle_post_compact(event) -> None:
        """Log that compaction finished and close the compaction bracket."""
        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"
        )
    
        try:
            from modules.session.compaction_bracket import close_bracket
    
            close_bracket(event.session_id)
        except Exception:
            logger.warning("PostCompact: compaction bracket not closed", exc_info=True)
        print(json.dumps({}))
        sys.exit(0)
    
    
    if __name__ == "__main__":
        run_hook(_handle_post_compact, hook_name="post_compact")
    
  • hooks/post_tool_use.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """
    Post-tool use hook - Thin gate.
    
    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_hook_logging("post_tool_use")
    logger = logging.getLogger(__name__)
    
    
    def _handle_post_tool_use(event) -> None:
        """Process a tool call's terminal event: PostToolUse or PostToolUseFailure.
    
        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)
    
    
    if __name__ == "__main__":
        run_hook(_handle_post_tool_use, hook_name="post_tool_use")
    
  • hooks/pre_compact.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """PreCompact hook — opens the compaction bracket, writes the session ledger, and returns an empty response.
    
    Claude Code neither validates nor consumes ``hookSpecificOutput`` for
    PreCompact, so this hook has no way to inject model context (see
    ``_handle_pre_compact``). It writes the session ledger (``gaia.session_ledger``)
    that a compact, resume or fork SessionStart renders instead.
    
    All errors are caught and logged — 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_hook_logging("pre_compact")
    logger = logging.getLogger(__name__)
    
    
    def _handle_pre_compact(event) -> None:
        """Open the compaction bracket, write the session ledger, answer ``{}``.
    
        Claude Code's hook-output schema has no ``hookSpecificOutput`` shape for
        PreCompact, so a ``hookEventName == "PreCompact"`` payload fails its JSON
        validation and ``additionalContext`` could not be applied anyway. The
        post-compaction refresh happens at SessionStart instead.
        """
        logger.info("PreCompact: no deliverable payload for this event, returning {}")
        try:
            from modules.session.compaction_bracket import open_bracket
    
            open_bracket(event.session_id)
        except Exception:
            logger.warning("PreCompact: compaction bracket not recorded", exc_info=True)
        try:
            from gaia.session_ledger import write_ledger
    
            write_ledger(event.session_id)
        except Exception:
            logger.warning("PreCompact: session ledger not written", exc_info=True)
    
        print(json.dumps({}))
        sys.exit(0)
    
    
    if __name__ == "__main__":
        run_hook(_handle_pre_compact, hook_name="pre_compact")
    
  • hooks/pre_tool_use.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """
    Pre-tool use hook - Thin Gate Architecture.
    
    Entry point for every PreToolUse validation hooks.json routes here (Bash, the
    Task/Agent dispatch, file tools and the rest). With Bash(*) in the settings.json
    allow list, all commands reach this hook regardless of settings.json
    permissions. 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.
    
    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
    
    def _deny_and_exit(message: str) -> NoReturn:
        """Deny the tool call with exit 2, using nothing outside the stdlib."""
        print(message, file=sys.stderr)
        print(json.dumps({
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": message,
            }
        }))
        sys.exit(2)
    
    
    try:
        _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
    
        # 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_hook_logging("pre_tool_use")
    except BaseException as _startup_error:
        if __name__ != "__main__":
            raise
        # The audit sink is part of what failed to load, so no event is recorded.
        _deny_and_exit(
            "[SECURITY GATE DEGRADED] The security gate failed to start and the "
            f"operation was blocked unvalidated.\nDetail: "
            f"{type(_startup_error).__name__}: {_startup_error}\n"
            "Run `gaia doctor` to find the broken component, or reinstall Gaia, then retry."
        )
    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.
    # Nothing orders the host's write against this process's first read, and a
    # zero-wait check lets the gate skip itself on a late write; the payload is
    # usually already buffered, so the wait costs nothing there. The deadline keeps
    # a pipe nobody writes to from stopping the session. GAIA_HOOK_STDIN_TIMEOUT
    # overrides 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:
        """Record a gate failure and deny the tool call (modules.security.fail_open).
    
        Every exit of this entry point that is not a delivered verdict is a gate
        failure. Imported lazily so an ordinary invocation does not pay for it.
        """
        from modules.security.fail_open import CAUSE_ERROR, decide_fail_open
    
        outcome = decide_fail_open(reason, detail, cause or CAUSE_ERROR)
        _trace(
            outcome.exit_code,
            payload=payload,
            blocked=outcome.blocked,
            extra={"fail_open": reason},
        )
        _deny_and_exit(outcome.message)
    
    
    def _exit_without_input() -> NoReturn:
        """Handle the case where no payload arrived before the deadline.
    
        Waiting narrows the race but cannot close it: the host may genuinely never
        write. No verdict was reached, so it is handled as any other gate failure
        and the call is denied.
    
        An interactive stdin is the one
  • hooks/session_end_hook.pyGitHub
    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.
    
    It reads CLAUDE_SESSION_ID and hands it to session_lifecycle.end_session;
    a registry failure never blocks shutdown.
    """
    
    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_lifecycle import end_session
    
    configure_hook_logging("session_end")
    logger = logging.getLogger(__name__)
    
    
    def _handle_session_end(event) -> None:
        """Unregister the session CLAUDE_SESSION_ID names and answer with an empty response.
    
        Args:
            event: Parsed HookEvent from the adapter layer.
        """
        end_session(os.environ.get("CLAUDE_SESSION_ID"))
        print(json.dumps({}))
        sys.exit(0)
    
    
    def main() -> None:
        """Run the SessionEnd handler under ``run_hook``; a named function so tests can call it without a subprocess."""
        run_hook(_handle_session_end, hook_name="session_end_hook")
    
    
    if __name__ == "__main__":
        main()
    
  • hooks/session_start.pyGitHub
  • hooks/stop_hook.pyGitHub
  • hooks/subagent_start.pyGitHub
  • hooks/subagent_stop.pyGitHub
  • hooks/task_completed.pyGitHub
  • hooks/user_prompt_submit.pyGitHub

All 12 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.

@metraton
Ships withgaia

Generative AI Interface for Agents. Specialist agents for Claude Code and OpenCode, with a memory that outlives the session and a consent gate on everything that changes state.

443 skills9 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
4
Star history of metraton/gaia on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3117k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in gaia.

Which coding agents can use the hooks in gaia?
The hooks in gaia install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.