Skip to content
Development
Hook

Hooks

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

From plugin
ai-plugin
95180 skills1 agent3 commands3 hooks
+1

Claude Code

> /plugin marketplace add PostHog/ai-plugin

What fires, and when

PostToolUse

  • MatchesSkill"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}/hooks/skill-invoked.sh"

SessionEnd

  • python3 "${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}/telemetry/hooks/session-end-llma.py"

PreToolUse

  • Matches__exec$"${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT}/hooks/gate-exec-write.sh"
Read hooks/hooks.json

In the plugin's words

How ai-plugin describes its own hook set.

PostHog LLM Analytics + permission gating for write commands via mcp__posthog__exec + anonymous skill-usage telemetry

Where it lives

  • hooks/codex-gate-exec-write.shGitHub
    Read the script
    #!/usr/bin/env bash
    # PermissionRequest gate for the PostHog MCP `exec` tool on Codex.
    #
    # Codex hooks cannot return "ask", so the plugin sets the posthog MCP server's
    # `default_tools_approval_mode` to `prompt` (codex-mcp.json): every `exec` call
    # reaches Codex's approval flow, and this hook runs first. It approves only
    # known reads: exec's read-only verbs and `call`s of tools on read-tools.txt, as
    # passed by `gate-exec-write.sh --fail-closed`. Every other command, including
    # any write, gets no verdict, so Codex prompts. The POSTHOG_MCP_EXEC_GATE_*
    # variables never approve a write here.
    #
    # Anything other than a clean pass from the shared gate — an ask, a missing
    # script, a crash — leaves Codex's prompt in place: this hook exits 0 without a
    # verdict. Codex reads exit 2 with stderr as a deny, so the shared gate's stderr
    # is discarded.
    
    input="$(cat)"
    gate="$(dirname -- "${BASH_SOURCE[0]}")/gate-exec-write.sh"
    
    verdict="$("$gate" --fail-closed <<<"$input" 2>/dev/null)" || exit 0
    [[ -z "$verdict" ]] || exit 0
    
    printf '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
    
  • hooks/gate-exec-write.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # PreToolUse gate for the PostHog MCP `exec` tool.
    #
    # The PostHog MCP exposes a single `exec` tool that dispatches subcommands like
    # `tools | search | info | schema | call <tool_name> [json]`. Once the user
    # allow-lists `mcp__posthog__exec`, every subsequent `call` (including writes
    # like `experiment-update`, `notebooks-destroy`, `cdp-functions-delete`) runs
    # without a prompt. This hook re-introduces a prompt for write `call`s by
    # returning `permissionDecision: "ask"`.
    #
    # Read-only PostHog tools and non-`call` exec verbs are left alone — the hook
    # exits 0 so normal permission flow applies. A tool is read-only only when
    # `hooks/read-tools.txt` lists it (every registry tool with readOnlyHint=true,
    # regenerated by scripts/generate-read-tools.sh); every other `call` counts as
    # a write, so tools added after the list was generated still reach the gate.
    # Third-party tools proxied through the MCP gateway (`<server>__<tool>`) are
    # left alone too.
    #
    # By default the prompt fires only for a curated "sensitive" subset of write
    # tools — feature-flag writes and any delete/destroy — rather than every write.
    # Three env vars tune this (all matched against the PostHog tool name):
    #
    #   POSTHOG_MCP_EXEC_GATE_DISABLE  — set to a non-empty value (e.g. `1`) to turn
    #       the gate off entirely. Useful for remote devboxes and Claude Cloud runs
    #       where the prompt can't be answered. Example:
    #
    #           export POSTHOG_MCP_EXEC_GATE_DISABLE=1
    #
    #   POSTHOG_MCP_EXEC_GATE_DENY     — comma-separated bash globs selecting which
    #       write tools prompt. Overrides the built-in default set. Use `*` to
    #       prompt on every write (the previous behaviour). Example:
    #
    #           export POSTHOG_MCP_EXEC_GATE_DENY="*feature-flag*,*-delete"
    #
    #   POSTHOG_MCP_EXEC_GATE_ALLOW    — comma-separated bash globs that opt specific
    #       write tools out of the prompt. Applied on top of the deny set (allow
    #       wins). Example:
    #
    #           export POSTHOG_MCP_EXEC_GATE_ALLOW="llma-skill-*,annotation-create"
    #
    # Unless the gate is disabled, `posthog-connection-call` and
    # `posthog-connection-forward` prompt regardless of DENY and ALLOW, as does any
    # input the hook can't read reliably.
    #
    # Pure bash; no jq or other third-party tools required. Relies on the fact
    # that PostHog tool names are kebab-case alphanumerics, so a narrow regex on
    # the raw JSON payload is safe.
    
    # `--fail-closed` is for hooks/codex-gate-exec-write.sh, which approves every
    # call this script passes silently. In that mode the script passes only known
    # reads (exec's read-only verbs and `call`s of tools on the read list), asks for
    # everything else whatever the POSTHOG_MCP_EXEC_GATE_* variables say, and lets
    # runtime failures exit non-zero, so the caller can tell a pass from a crash.
    fail_closed=0
    [[ "${1:-}" == --fail-closed ]] && fail_closed=1
    
    # Otherwise fail open — this gate must never break a Claude Code tool call.
    #
    # The "ask" decision is delivered entirely through the stdout JSON below; the
    # exit code is never used to signal it. So we force every exit path to 0 via an
    # EXIT trap. Any unexpected runtime failure — an unbound variable under `set -u`,
    # a failed builtin, an unusually old bash — then falls through to normal
    # permission flow instead of surfacing as a hook error. Crucially, it can never
    # exit 2, which Claude Code interprets as a hard *block* of the tool call.
    #
    # The one failure a trap can't catch is a parse-time syntax error (the trap
    # isn't installed yet); `tests/test_gate_exec_write.sh` guards that with `bash -n`.
    (( fail_closed )) || trap 'exit 0' EXIT
    
    set -u
    
    # Prompts with reason $1 and exits. The reason is interpolated into JSON
    # unescaped, so callers pass fixed text or a [a-zA-Z0-9_-]+ tool name.
    ask() {
        printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}' "$1"
        exit 0
    }
    
    # Passes in normal mode; asks in fail-closed mode.
    not_a_known_read() {
        (( fail_closed )) || exit 0
        ask "The PostHog exec command is not a known read — approve to run."
    }
    
    # Codex compatibility: Codex's PreToolUse protocol does not support
    # `permissionDecision: "ask"` (it is parsed then rejected as unsupported), so
    # Codex runs this script through hooks/codex-gate-exec-write.sh instead. Skip the
    # gate if a Codex build loads it from hooks/hooks.json directly, detected via its
    # native PLUGIN_ROOT env var — Claude Code only ever sets CLAUDE_PLUGIN_ROOT,
    # never PLUGIN_ROOT. See https://developers.openai.com/codex/hooks
    if [[ -n "${PLUGIN_ROOT:-}" ]] && (( ! fail_closed )); then
        exit 0
    fi
    
    # Full opt-out — turn the gate off entirely. Set to any non-empty value other
    # than `0`. Lets remote devboxes and Claude Cloud runs, where no one can answer
    # the prompt, run write `call`s without interruption. In fail-closed mode it
    # would approve every write, so it is ignored there.
    if [[ -n "${POSTHOG_MCP_EXEC_GATE_DISABLE:-}" && "${POSTHOG_MCP_EXEC_GATE_DISABLE}" != "0" ]] && (( ! fail_closed )); then
        exit 0
    fi
    
    # Default set of write tools that prompt. Comma-separated bash globs, grounded in
    # the live PostHog MCP tool registry. Covers the genuinely sensitive surface:
    #
    #   *feature-flag*  — feature-flag rollout writes: create-feature-flag,
    #                     update-feature-flag, delete-feature-flag,
    #                     feature-flags-bulk-{delete,update-tags}-create,
    #                     feature-flags-copy-flags-create
    #   *delete*        — any deletion: insight-delete, dashboard-delete,
    #                     cdp-functions-delete, persons-bulk-delete,
    #                     external-data-schemas-delete-data, session-recording-delete, …
    #   *destroy*       — any destroy: notebooks-destroy, accounts-destroy,
    #                     experiment-saved-metrics-destroy, agent-applications-destroy, …
    #   experiment-launch / experiment-ship-variant / experiment-reset
    #                   — start exposing users / roll a variant to everyo
  • hooks/skill-invoked.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # Skill telemetry — anonymous plugin usage analytics.
    #
    # Fires on PostToolUse(Skill), when Claude invokes a skill, and on
    # UserPromptExpansion, when the user types `/posthog:<skill>`. *Only for this
    # plugin's own skills*, it sends a single "plugin skill invoked" event to a
    # PostHog-owned project so the team can see which skills get used in the field.
    # Captures the skill name plus safe metadata only — never the skill arguments or
    # any free-text content.
    #
    # Privacy & consent:
    #   - Anonymous: distinct_id is a random per-install UUID (no email, no PII),
    #     and events set `$process_person_profile: false` so no person is created.
    #     Ingestion copies the request IP into an empty `$ip`, so `$ip` carries a
    #     fixed loopback address instead, and `$geoip_disable` skips the lookup.
    #   - Opt-out (on by default). Disable with any of:
    #         export DO_NOT_TRACK=1
    #         export POSTHOG_PLUGIN_TELEMETRY_DISABLED=1
    #         export DISABLE_TELEMETRY=1
    #         export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
    #     It is also off on Bedrock, Vertex, Foundry, and Claude Platform on AWS,
    #     like Claude Code's own telemetry.
    #   - Endpoint/key are overridable for testing via POSTHOG_PLUGIN_TELEMETRY_KEY
    #     and POSTHOG_PLUGIN_TELEMETRY_HOST.
    #
    # Pure bash + curl; no jq or other third-party tools. Reads the hook JSON on
    # stdin, sends in the background, and always exits 0 with no output — losing a
    # telemetry event is fine, interfering with the user's session is not, and
    # UserPromptExpansion stdout would reach Claude's context.
    #
    # Every value interpolated into the JSON body is matched against or stripped to
    # a quote-free charset first, so no JSON escaping is needed.
    
    set -u
    
    # PostHog project (public, write-only ingestion key — safe to ship).
    API_KEY="${POSTHOG_PLUGIN_TELEMETRY_KEY:-sTMFPsFhdP1Ssg}"
    HOST="${POSTHOG_PLUGIN_TELEMETRY_HOST:-https://us.i.posthog.com}"
    
    # --- consent guards (opt-out) --------------------------------------------
    # DO_NOT_TRACK: any value other than empty/0/false means "do not track".
    case "${DO_NOT_TRACK:-}" in
        ""|0|false|FALSE|False) ;;
        *) exit 0 ;;
    esac
    case "${POSTHOG_PLUGIN_TELEMETRY_DISABLED:-}" in
        ""|0|false|FALSE|False) ;;
        *) exit 0 ;;
    esac
    # Claude Code's opt-outs: any non-empty value, even 0 or false, opts out.
    [[ -z "${DISABLE_TELEMETRY:-}" && -z "${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-}" ]] || exit 0
    # Claude Code's telemetry is off by default on third-party providers.
    for provider in "${CLAUDE_CODE_USE_BEDROCK:-}" "${CLAUDE_CODE_USE_VERTEX:-}" "${CLAUDE_CODE_USE_FOUNDRY:-}" "${CLAUDE_CODE_USE_ANTHROPIC_AWS:-}"; do
        case "$(printf '%s' "$provider" | tr '[:upper:]' '[:lower:]')" in
            1|true|yes|on) exit 0 ;;
        esac
    done
    # Self-hosted users point the MCP server or LLM analytics at their own
    # instance; keep their usage off PostHog Cloud. Unparseable hosts fail closed.
    for url in "${POSTHOG_MCP_URL:-}" "${POSTHOG_HOST:-}"; do
        [[ -n "$url" ]] || continue
        host="${url#*://}"
        host="${host%%[/?#]*}"
        host="$(printf '%s' "${host%%:*}" | tr '[:upper:]' '[:lower:]')"
        [[ "$host" == posthog.com || "$host" == *.posthog.com ]] || exit 0
    done
    # An empty or malformed key override → no-op.
    [[ "$API_KEY" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0
    
    # curl is required to send; if absent, no-op silently.
    command -v curl >/dev/null 2>&1 || exit 0
    
    ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)}"
    
    input="$(cat)"
    
    # Whether arguments were passed is kept as presence only, never the content.
    args_present=false
    if [[ "$input" =~ \"hook_event_name\"[[:space:]]*:[[:space:]]*\"UserPromptExpansion\" ]]; then
        invocation=user
        # The input is flat, and a `"` inside a JSON string is always escaped, so
        # these key matches can't start inside the user's prompt or args.
        [[ "$input" =~ \"expansion_type\"[[:space:]]*:[[:space:]]*\"slash_command\" ]] || exit 0
        [[ "$input" =~ \"command_source\"[[:space:]]*:[[:space:]]*\"plugin\" ]] || exit 0
        [[ "$input" =~ \"command_name\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]] || exit 0
        name="${BASH_REMATCH[1]}"
        if [[ "$input" =~ \"command_args\"[[:space:]]*:[[:space:]]*\"[^\"] ]]; then
            args_present=true
        fi
    else
        invocation=model
        [[ "$input" =~ \"tool_name\"[[:space:]]*:[[:space:]]*\"Skill\" ]] || exit 0
        # Walk tool_input's top level one whole JSON string or non-brace byte at a
        # time, so a key match can't start inside a string (args) or leave the
        # object (tool_response). Group 3 is the value.
        tool_input='"tool_input"[[:space:]]*:[[:space:]]*\{([^"{}]|"([^"\\]|\\.)*")*'
        [[ "$input" =~ ${tool_input}\"skill\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]] || exit 0
        name="${BASH_REMATCH[3]}"
        if [[ "$input" =~ ${tool_input}\"args\"[[:space:]]*:[[:space:]]*\"[^\"] ]]; then
            args_present=true
        fi
    fi
    
    # Claude Code namespaces plugin skills, so a bare name may be the user's own
    # skill; count only `posthog:<bundled skill>`.
    [[ "$name" =~ ^posthog:([a-zA-Z0-9_-]+)$ ]] || exit 0
    skill="${BASH_REMATCH[1]}"
    [[ -d "$ROOT/skills/$skill" ]] || exit 0
    
    # permission_mode is a short enum (default/acceptEdits/plan/...).
    permission_mode=""
    if [[ "$input" =~ \"permission_mode\"[[:space:]]*:[[:space:]]*\"([a-zA-Z]+)\" ]]; then
        permission_mode="${BASH_REMATCH[1]}"
    fi
    
    plugin_version="unknown"
    if [[ "$(cat "$ROOT/.claude-plugin/plugin.json" 2>/dev/null)" =~ \"version\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
        plugin_version="${BASH_REMATCH[1]//[^A-Za-z0-9.+-]/}"
    fi
    
    os="$(uname -s 2>/dev/null)"
    os="${os//[^A-Za-z0-9._-]/}"
    
    # Stable anonymous per-install id (UUID). No PII — deliberately not git email.
    data_dir="${CLAUDE_PLUGIN_DATA:-$HOME/.claude}"
    id_file="$data_dir/posthog-plugin-telemetry-id"
    distinct_id="$(tr -d '[:space:]' 2>/dev/null < "$id_file")"
    if [[ ! "$distinct_id" =~ ^[A-Za-z0-9-]+$ ]]; then
        distinct_id="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/ran

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.

@posthog
Ships withai-plugin

Official PostHog plugin for AI clients. Access PostHog products directly from your AI coding tool.

95180 skills1 agent1 MCP
Get the whole plugin

Stars on GitHub

Momentum

stars in total
95
Star history of PostHog/ai-plugin 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+3112k
@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 ai-plugin.

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