Claude Code hooks: every event, the settings.json shape, and tested recipes (plus an X data budget guard)

By TwitterAPI.io Team•20 min read
Dark diagram of how a Claude Code hook resolves, in four boxes. 1 Event fires: PreToolUse with tool_name mcp__twitterapi-mcp__search_tweets, JSON on stdin. 2 Matcher: "mcp__twitterapi-mcp__.*", evaluated as an unanchored regex because it contains a dot and a star. 3 Handler runs: a command hook, x_budget_guard.py, reads a ledger and adds a worst case of 300 credits per page. 4 Verdict: exit 0 with no output follows the normal flow, exit 0 with JSON can allow, deny, ask or defer, exit 2 blocks and sends stderr to Claude. Footer: 33 documented events, 5 handler types (command, http, mcp_tool, prompt, agent); X study collected in 2026, 927 posts, 10.2% name a documented event and PreToolUse leads with 46.3% of those.
Event, matcher, handler, verdict: the four steps every Claude Code hook goes through, shown with the X data budget guard from this page.

Hooks are the deterministic layer of Claude Code. A CLAUDE.md rule asks the model to remember to run Prettier or to never touch .env; a hook makes it happen every time, because Claude Code itself runs the handler at the lifecycle point you pick, whatever the model decides. That is why the hooks reference reads more like an API reference than a tips page: an event list, a JSON input schema per event, an exit-code contract and a JSON output schema.

This page is that reference in one place, checked against Anthropic's own hooks reference and hooks guide at code.claude.com/docs/en/hooks and code.claude.com/docs/en/hooks-guide this session, plus recipes we ran with sample stdin before writing them up. Version-gated fields carry the Claude Code version the docs name. We also searched X for how developers talk about hooks, which shows which events people actually reach for, and we close with a hook pair that logs and caps the X data an agent pulls through the twitterapi.io MCP server.

If you are still choosing a coding agent, the pricing and plan limits are on our Claude Code pricing page; this page assumes Claude Code is installed and you can edit its settings files.

01 — Section

All 33 Claude Code hook events, and which ones can block

The hooks reference lists 33 events. They fall into three cadences: per session (SessionStart, SessionEnd), per turn (UserPromptSubmit, Stop, StopFailure), and per tool call inside the agentic loop (PreToolUse, PostToolUse and their relatives). The rest are standalone events such as FileChanged, CwdChanged and the model-switch pair. The third column is what exit code 2 does on that event, which is the quickest way to know whether a hook there can stop anything.

EventWhen it firesExit 2 blocks?
SessionStarta session begins or resumes (matcher: startup, resume, clear, compact, fork)No
Setup--init-only, or --init / --maintenance in -p modeNo
UserPromptSubmita prompt is submitted, before Claude sees itYes, rejects the prompt
UserPromptExpansiona typed command expands into a promptYes
PreToolUsebefore a tool call runs (matcher: tool name)Yes, blocks the call
PermissionRequesta tool call needs a permission decisionNo, use the JSON decision
PermissionDeniedauto mode denies a tool callNo
PostToolUseafter a tool call succeedsNo, stderr goes to Claude
PostToolUseFailureafter a tool call failsNo, stderr goes to Claude
PostToolBatchafter a batch of parallel tool calls resolvesYes, stops the loop
NotificationClaude Code sends a notification (permission_prompt, idle_prompt, ...)No
MessageDisplaywhile assistant text is displayedNo
SubagentStarta subagent is spawnedNo
SubagentStopa subagent finishesYes, keeps it working
TaskCreateda task is created via TaskCreateYes, rolls it back
TaskCompleteda task is marked completedYes
StopClaude finishes responding (not on user interrupt)Yes, Claude keeps going
StopFailurethe turn ends on an API errorNo, output ignored
TeammateIdlean agent-team teammate is about to go idleYes
InstructionsLoadeda CLAUDE.md or .claude/rules/*.md file loadsNo
ConfigChangea configuration file changes mid-sessionYes (not policy_settings)
CwdChangedthe working directory changesNo
DirectoryAddeda directory is added via /add-dirNo
FileChangeda watched file changes on diskNo
WorktreeCreatea worktree is being createdAny non-zero exit fails it
WorktreeRemovea hook-created worktree is removedAny non-zero exit fails it
PreCompactbefore context compaction (manual, auto)Yes, blocks compaction
PostCompactafter compaction completesNo
PreModelSwitchbefore a requested model switchYes, blocks the switch
PostModelSwitchafter the session's model changesNo
Elicitationan MCP server asks for user input mid-callYes, declines it
ElicitationResultafter the user answers an elicitationYes, response becomes decline
SessionEnda session terminatesNo

Matchers differ by event. Tool events match tool_name; SessionStart matches how the session started; Notification matches the notification type (permission_prompt, idle_prompt, auth_success, elicitation_dialog, agent_needs_input, and more); PreCompact and PostCompact match manual or auto; StopFailure matches the error type (rate_limit, overloaded, billing_error, ...). UserPromptSubmit, Stop, PostToolBatch, TeammateIdle, TaskCreated, TaskCompleted, the worktree pair, MessageDisplay and CwdChanged have no matcher support: a matcher there is silently ignored and the hook fires every time.

What people actually use. In our X sample, posts that name an event mostly name four: PreToolUse (46.3%), Stop (24.2%), SessionStart (20.0%) and PostToolUse (18.9%). 18 of the 33 events, including PostToolBatch, FileChanged, ConfigChange and the model-switch pair, did not appear once. The newer events are documented and working; they are simply not yet part of the shared recipes people copy.

Horizontal bar chart of which Claude Code hook events X posts name, as a share of the 95 event-naming posts among 927 posts about Claude Code hooks. PreToolUse 46.3% (44 posts), Stop 24.2%, SessionStart 20.0%, PostToolUse 18.9%, UserPromptSubmit 10.5%, SubagentStop 10.5%, PreCompact 4.2%, SessionEnd 4.2%, SubagentStart 3.2%, Notification 2.1%. 18 of 33 documented events never appear.
Which documented hook events X posts about Claude Code hooks name, collected in 2026. PreToolUse leads by a wide margin; most of the newer events never appear.
02 — Section

The settings.json shape: event, matcher group, handler

Hooks live under a top-level hooks key in a settings file, nested three levels deep: the event name maps to an array of matcher groups, and each matcher group holds a matcher string plus a hooks array of handlers. Where the file sits sets the scope.

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo, local to your machine
.claude/settings.jsonOne projectYes, commit it
.claude/settings.local.jsonOne projectNo, gitignored
Managed policy settingsOrganization-wideAdmin-controlled
Plugin hooks/hooks.json, skill or subagent frontmatterWhile the plugin is enabled or the skill / subagent is activeYes

Entries merge across levels instead of replacing each other, and the same handler defined in two settings files runs once. Matching hooks for one event run in parallel. Edits to settings files are normally picked up by a file watcher; the /hooks command opens a read-only browser that shows every configured hook and the file it came from.

Handler types. command runs a shell command or an executable; http POSTs the event JSON to a URL and reads the same JSON output from the response body; mcp_tool calls a tool on an MCP server you already configured; prompt asks a Claude model for a one-shot decision; agent spawns a subagent that can read files before deciding (the docs mark agent hooks experimental).

Common fields. type (required), if (one permission rule such as Bash(git *) or Edit(*.ts), evaluated only on tool events), timeout in seconds (defaults: 600 for command, http and mcp_tool, lowered to 30 on UserPromptSubmit and the model-switch events; 30 for prompt; 60 for agent), and statusMessage for the spinner text. Command hooks add command, args, async, asyncRewake, shell and onFailure (Claude Code v2.1.295 or later).

Exec form vs shell form. With args present, command is resolved as an executable and spawned with no shell, so a path placeholder such as ${CLAUDE_PROJECT_DIR} needs no quoting. Without args, the string goes to sh -c (Git Bash or PowerShell on Windows), which gives you pipes and && but means every placeholder must sit in double quotes.

Environment variables. Every command hook gets CLAUDE_PROJECT_DIR (the root where the session started; it does not follow Claude into a worktree, read cwd from the input for that), and plugin hooks also get CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA. CLAUDE_ENV_FILE exists only for SessionStart, Setup, CwdChanged and FileChanged: append export lines to it to set variables for every later Bash command. CLAUDE_CODE_REMOTE is true in cloud sessions, CLAUDE_EFFORT carries the effort level, and there is no CLAUDE_MODEL variable.

This project file registers the three everyday hooks on this page: a formatter after edits, a guard before Bash and file writes, and a desktop ping on notifications.

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh",
            "args": [],
            "timeout": 30
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py"],
            "onFailure": "block"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/notify.sh", "args": [] }
        ]
      }
    ]
  }
}
03 — Section

Matcher syntax, MCP tool names and the if field

How Claude Code reads a matcher depends on the characters in it, and this is where most broken hooks come from.

MatcherRead asExample
*, empty, or omittedMatch everythingfires on every occurrence
Only letters, digits, _, -, spaces, commas and the pipe characterExact name, or a list of exact names split by pipe or commaBash; Edit, Write
Any other characterUnanchored JavaScript regex^Notebook; mcp__memory__.*

Pipe lists. Edit|Write is the same exact-name list as Edit, Write. Unanchored means substring. Edit.* matches both Edit and NotebookEdit. Write ^Edit$ when you mean exactly one tool. Matchers are case-sensitive, so bash matches nothing.

MCP tools. MCP server tools appear in tool events as mcp__<server>__<tool>. To catch every tool from one server you need the .* suffix: mcp__memory has only exact-match characters, so it is compared as an exact string and matches no tool at all. mcp__brave-search__.* works for hyphenated server names, mcp__.*__write.* catches write tools on any server, and a plugin-bundled server uses a scoped segment such as mcp__plugin_my-plugin_db__.*. Since Claude Code v2.1.274 the input also carries an mcp_server object with the server name and a source; the docs recommend basing trust decisions on source rather than on the tool-name prefix.

The if field narrows further. A handler-level if holds exactly one permission rule and is only evaluated on tool events (on any other event a hook with if never runs). For Bash, leading VAR=value assignments are stripped and each subcommand is checked, so Bash(git *) fires on npm test && git push, and commands inside $() and backticks are checked too. When Claude Code cannot tell which commands a Bash string runs, it runs the hook anyway. The docs are explicit that this filter is best-effort: use permission rules, not an if pattern, for a hard allow or deny.

Which file tools. For Write, Edit and Read, tool_input.file_path always arrives absolute (Claude Code expands ~ and relative paths first), with backslashes on Windows. A path check written with forward slashes never matches on Windows and lets the call through.

04 — Section

Exit codes and JSON output: how a hook decides

A command hook gets the event JSON on stdin: common fields (session_id, transcript_path, cwd, permission_mode, hook_event_name, and prompt_id once there is user input) plus event fields such as tool_name, tool_input and tool_use_id for tool events, tool_response and duration_ms for PostToolUse, and stop_hook_active plus last_assistant_message for Stop. It answers with an exit code and, optionally, one JSON object on stdout.

Exit codeEffect
0Success. JSON fields on stdout apply; the action goes ahead unless they block it
2Blocking error on events that can block. stderr becomes the reason Claude sees; JSON cannot override the block
Anything elseNon-blocking error: the action goes ahead and a hook-error notice shows the first stderr line

Exit 1 does not block. It is the usual Unix failure code, but in Claude Code it is a non-blocking error. A policy hook must exit 2, return a deny decision, or set "onFailure": "block", which also turns a missing script, a crash or a timeout into a block. A timed-out PreToolUse hook without that flag lets the call continue through the normal permission flow.

JSON output. Print only one JSON object; a line your shell profile echoes first is enough to make Claude Code read the output as plain text. Universal fields: continue (false stops Claude entirely), stopReason, systemMessage (a warning shown to the user) and terminalSequence (a desktop notification, title or bell, because hooks have no /dev/tty). Event-specific control comes in two styles: a top-level decision: "block" plus reason for UserPromptSubmit, PostToolUse, PostToolBatch, Stop, SubagentStop, ConfigChange and PreCompact, and a hookSpecificOutput object with hookEventName for PreToolUse, PermissionRequest and others.

PreToolUse decisions. permissionDecision is allow, deny, ask or defer (defer needs -p mode), with permissionDecisionReason; updatedInput replaces the whole tool input before it runs; additionalContext adds a note next to the tool result. When several hooks disagree, deny beats defer, which beats ask, which beats allow. A hook's allow never overrides a deny rule in settings, while a hook's deny holds in every permission mode, including the mode that --dangerously-skip-permissions turns on. The old top-level approve / block values for this event are deprecated.

PostToolUse and Stop. PostToolUse cannot undo anything (the tool already ran); decision: "block" puts your reason next to the result, and updatedToolOutput replaces what Claude sees. A Stop hook that blocks makes Claude keep working; check stop_hook_active to avoid loops, because Claude Code overrides the block after eight consecutive continuations (raise it with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). additionalContext, systemMessage and plain stdout are each capped at 10,000 characters; longer text is saved to a file and Claude gets a preview.

Context injection. On SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch, plain-text stdout is added to Claude's context as-is; elsewhere stdout only goes to the debug log (claude --debug, then ~/.claude/debug/<session-id>.txt). The docs advise writing injected text as facts ("This repo uses bun test") rather than as commands.

json
// PreToolUse: deny with a reason Claude sees
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

// PostToolUse / Stop / UserPromptSubmit: top-level decision
{ "decision": "block", "reason": "Test suite must pass before proceeding" }

// Any event: stop Claude entirely
{ "continue": false, "stopReason": "Build failed, fix errors before continuing" }
05 — Section

Recipe 1 and 2: a PostToolUse formatter and a PreToolUse guard

The formatter. A PostToolUse hook on Edit|Write reads tool_input.file_path and formats that one file by extension. It always exits 0: the edit already happened, and a formatter that blocks only confuses Claude. We tested it by piping a sample Write payload for a file containing const a = {b:1}; Prettier rewrote it to const a = { b: 1 }; and the hook printed nothing, which is the expected success path. The docs' one-liner version is jq -r '.tool_input.file_path' | xargs npx prettier --write.

The guard. A PreToolUse hook on Bash|Edit|Write denies a recursive force delete, a force push to main, piping a download into a shell, and writes to .env, key files or .git/. It returns a JSON deny instead of exiting 2 so the reason reaches Claude cleanly, and stays silent otherwise so the normal permission flow decides. Register it with "onFailure": "block" so a crashed guard fails closed. Treat it as a seatbelt, not a sandbox: pattern matching on shell strings misses creative spellings, which is why Anthropic's docs point hard allow / deny rules at the permission system.

The notifier. The notify.sh registered in the settings above returns a terminalSequence (OSC 777) that Claude Code writes for you; Notification hooks cannot block and their systemMessage is discarded, but the sequence still fires in an interactive session.

Test before you register. Pipe sample JSON into each script and check stdout and the exit code, exactly as the troubleshooting section of the guide suggests. These are the runs we did for this page:

bash
#!/bin/bash
# PostToolUse hook (matcher "Edit|Write"): format the file Claude just wrote.
# Reads the hook JSON on stdin; tool_input.file_path is always absolute.
file=$(jq -r '.tool_input.file_path // empty')
[ -z "$file" ] || [ ! -f "$file" ] && exit 0

case "$file" in
  *.py)                  ruff format --quiet "$file" 2>/dev/null ;;
  *.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
                         npx --no-install prettier --write --log-level silent "$file" 2>/dev/null ;;
  *.go)                  gofmt -w "$file" 2>/dev/null ;;
esac
exit 0   # never block: the edit already happened

# ---- guard.py ----
#!/usr/bin/env python3
"""PreToolUse hook (matcher "Bash|Edit|Write"): deny a few dangerous actions.

Exit 0 + JSON permissionDecision "deny" blocks the call and shows the reason to Claude.
Exit 0 with no output leaves the normal permission flow in charge.
"""
import json, re, sys

data = json.load(sys.stdin)
tool = data.get("tool_name", "")
inp = data.get("tool_input", {}) or {}

BASH_RULES = [
    (r"\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r", "recursive force delete"),
    (r"\bgit\s+push\b.*(--force|-f\b).*\b(main|master)\b", "force push to main"),
    (r"\bcurl\b[^|]*\|\s*(sh|bash)\b", "piping a download into a shell"),
]
PROTECTED = (".env", ".env.local", "id_rsa", ".pem", "/.git/")

reason = None
if tool == "Bash":
    cmd = inp.get("command", "")
    for pattern, label in BASH_RULES:
        if re.search(pattern, cmd):
            reason = f"Blocked by guard.py: {label}. Ask the user to run it by hand."
            break
elif tool in ("Edit", "Write"):
    path = inp.get("file_path", "")
    if any(path.endswith(p) or p in path for p in PROTECTED):
        reason = f"Blocked by guard.py: {path} is a protected file."

if reason:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": reason}}))
sys.exit(0)

# ---- notify.sh ----
#!/bin/bash
# Notification hook: desktop ping through Claude Code's own terminal write path.
# Hooks have no /dev/tty, so return the escape sequence in "terminalSequence".
input=$(cat)
body=$(jq -r '.message // "Needs your attention"' <<<"$input")
seq=$(printf '\033]777;notify;%s;%s\007' "Claude Code" "$body")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'

# ---- tests ----
# Test every hook before you register it: pipe sample stdin JSON, check stdout and exit code
$ echo '{"tool_name":"Bash","tool_input":{"command":"cd build && rm -rf /"}}' | ./guard.py; echo " exit=$?"
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Blocked by guard.py: recursive force delete. Ask the user to run it by hand."}}
 exit=0

$ echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' | ./guard.py; echo "[empty] exit=$?"
[empty] exit=0

$ echo '{"tool_name":"mcp__twitterapi-mcp__search_tweets","tool_input":{"query":"x"}}' \
    | X_DAILY_BUDGET_CREDITS=500 ./x_budget_guard.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "X data budget reached: 318 of 500 credits used today ($0.0032). This search_tweets call could add 300."}}

$ X_LEDGER=/tmp/l.jsonl ./x_call_log.py < post_search.json && tail -1 /tmp/l.jsonl
{"day": "...", "tool": "search_tweets", "args": {"query": "claude code hooks", "queryType": "Latest"}, "tweets": 20, "users": 0, "credits": 300, "usd": 0.003, "duration_ms": 812, ...}
06 — Section

Recipe 3: log and cap X API calls made through an MCP server

Give an agent a Twitter (X) data tool and it will happily run a search forty times while it iterates. Because MCP tools are ordinary tools in PreToolUse and PostToolUse, two small hooks turn that into a metered, capped resource. We sent the hosted twitterapi.io MCP server an initialize and a tools/list request this session: it answers as twitterapi-mcp and lists 12 tools (search_tweets, get_user_info, get_user_about, get_user_followers, get_user_followings, get_user_last_tweets, get_user_mentions, get_tweets_by_ids, get_tweet_replies, get_tweet_quotes, get_tweet_retweeters, get_trends), and its instructions say to pass your API key as the Bearer token. Added under the name twitterapi-mcp, the tools show up as mcp__twitterapi-mcp__search_tweets and so on, so one matcher, mcp__twitterapi-mcp__.*, covers all of them.

x_call_log.py (PostToolUse) walks the tool result, counts the tweets or users it returned, prices the call (15 credits per tweet with a 15-credit minimum, 18 per profile) and appends one JSON line to ~/.claude/x-ledger.jsonl. Fed a real 20-tweet search page wrapped as MCP text content, it logged 20 tweets, 300 credits, $0.003.

x_budget_guard.py (PreToolUse) sums today's ledger, adds the worst case for the call about to run (a full 20-tweet page is 300 credits), returns ask above 80% of the budget and deny above 100%, with the numbers in the reason so Claude can tell you why. With onFailure: "block", a missing or broken guard stops X calls instead of letting them through unmetered. In our test with a 500-credit budget and 318 credits already logged, it denied the next search with the message shown above.

The X data bill. twitterapi.io charges 15 credits per tweet returned ($0.15 per 1,000 tweets; a full 20-tweet page is $0.003, an empty search costs the $0.00015 minimum) and 18 credits per profile ($0.18 per 1,000). The official X API pay-per-usage rate card at docs.x.com/x-api/getting-started/pricing lists $0.005 per post read and $0.010 per user read, so the same 1,000 tweets cost $5 there against $0.15 here, roughly 33x. Our study for this page pulled 1,335 tweets in 74 requests for $0.20; at X's post-read rate that is $6.67.

Daily budgetCreditsFull 20-tweet pagesTweetsSame tweets on X pay-per-usage
$0.3030,0001002,000$10.00
$1.00100,0003336,660$33.30
$3.00300,0001,00020,000$100.00

The MCP server's tools all read data. For writes, use the REST write endpoints instead (Recipe 4). New accounts that pass the automatic bot check get up to $1 of free credits (at most 100,000 credits, no card, one per person or network, expiring after 31 days), which is enough to run this guard with the default budget for three days; purchased credits never expire. Plans and rate limits are on the pricing page.

bash
# 1. Add the hosted twitterapi.io MCP server (Streamable HTTP, key as a Bearer token)
claude mcp add --transport http --scope user twitterapi-mcp https://mcp.twitterapi.io/mcp \
  --header "Authorization: Bearer YOUR_API_KEY"

# 2. Its tools now appear as mcp__twitterapi-mcp__<tool>, for example
#    mcp__twitterapi-mcp__search_tweets, mcp__twitterapi-mcp__get_user_info

# 3. Drop the two hook scripts into the project and register them (settings below)
mkdir -p .claude/hooks && cp x_budget_guard.py x_call_log.py .claude/hooks/

# 4. Optional: set the daily budget in credits ($1 = 100,000 credits)
export X_DAILY_BUDGET_CREDITS=30000     # $0.30 a day, 100 full search pages

# ---- settings.json ----
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__twitterapi-mcp__.*",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/x_budget_guard.py"],
            "onFailure": "block",
            "statusMessage": "Checking X data budget"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "mcp__twitterapi-mcp__.*",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/x_call_log.py"]
          }
        ]
      }
    ]
  }
}

# ---- .claude/hooks/x_budget_guard.py ----
#!/usr/bin/env python3
"""PreToolUse hook (matcher "mcp__twitterapi-mcp__.*"): a daily X data budget.

Reads today's spend from the ledger that x_call_log.py writes, adds a worst-case
estimate for the call Claude is about to make, and asks or denies when the total
would cross the budget. Prices: twitterapi.io pay-as-you-go, 15 credits per tweet
returned (minimum 15 per request), 18 credits per profile; $1 = 100,000 credits.
"""
import datetime, json, os, sys

BUDGET = int(os.environ.get("X_DAILY_BUDGET_CREDITS", "30000"))   # 30,000 credits = $0.30
LEDGER = os.path.expanduser(os.environ.get("X_LEDGER", "~/.claude/x-ledger.jsonl"))

# Worst-case credits per call: a full page of 20 tweets x 15 credits = 300.
TWEET_PAGE = 20 * 15
ESTIMATE = {
    "search_tweets": TWEET_PAGE, "get_user_last_tweets": TWEET_PAGE,
    "get_user_mentions": TWEET_PAGE, "get_tweet_replies": TWEET_PAGE,
    "get_tweet_quotes": TWEET_PAGE,
    "get_user_info": 18, "get_user_about": 18,
}
DEFAULT = TWEET_PAGE  # conservative placeholder for tools not listed above

data = json.load(sys.stdin)
tool = data["tool_name"].split("__")[-1]          # mcp__twitterapi-mcp__search_tweets -> search_tweets
args = data.get("tool_input", {}) or {}
if tool == "get_tweets_by_ids":
    estimate = 15 * max(1, len(str(args.get("tweet_ids", "")).split(",")))
else:
    estimate = ESTIMATE.get(tool, DEFAULT)

today = datetime.date.today().isoformat()
spent = 0
if os.path.exists(LEDGER):
    with open(LEDGER) as f:
        for line in f:
            try:
                row = json.loads(line)
            except ValueError:
                continue
            if row.get("day") == today:
                spent += int(row.get("credits", 0))

after = spent + estimate
decision = None
if after > BUDGET:
    decision = ("deny", f"X data budget reached: {spent:,} of {BUDGET:,} credits used today "
                        f"(${spent/100000:.4f}). This {tool} call could add {estimate:,}.")
elif after > 0.8 * BUDGET:
    decision = ("ask", f"{tool} would bring today's X data spend to about {after:,} of "
                       f"{BUDGET:,} credits. Allow?")

if decision:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": decision[0],
        "permissionDecisionReason": decision[1]}}))
sys.exit(0)

# ---- .claude/hooks/x_call_log.py ----
#!/usr/bin/env python3
"""PostToolUse hook (matcher "mcp__twitterapi-mcp__.*"): log every X call and its bill.

Counts the tweets / users the tool returned and appends one JSON line per call to
the ledger that x_budget_guard.py reads. Billing model: 15 credits per tweet
returned (minimum 15 per request), 18 credits per profile.
"""
import datetime, json, os, sys

LEDGER = os.path.expanduser(os.environ.get("X_LEDGER", "~/.claude/x-ledger.jsonl"))


def find_lists(node, out):
    """Walk the MCP result (content blocks may hold JSON text) and collect list lengths."""
    if isinstance(node, str):
        s = node.strip()
        if s.startswith("{") or s.startswith("["):
            try:
                find_lists(json.loads(s), out)
            except ValueError:
                pass
    elif isinstance(node, dict):
        for key, val in node.items():
            if key in ("tweets", "followers", "followings", "users") and isinstance(val, list):
                out[key] = out.get(key, 0) + len(val)
            else:
                find_lists(val, out)
    elif isinstance(node, list):
        for item in node:
            find_lists(item, out)


data = json.load(sys.stdin)
tool = data["tool_name"].split("__")[-1]
counts = {}
find_lists(data.get("tool_response"), counts)
tweets = counts.get("tweets", 0)
users = counts.get("users", 0) + counts.get("followers", 0) + counts.get("followings", 0)

if tool in ("get_user_info", "get_user_about"):
    credits = 18
elif users and not tweets:
    credits = users * 3                     # follower pages bill 1-3 credits per user; log the upper end
else:
    credits = max(15, tweets * 15)          # tweet endpoints: 15 per tweet, 15 minimum

row = {"day": datetime.date.today().isoformat(), "tool": tool,
       "args": data.get("tool_input"), "tweets": tweets, "users": users,
       "credits": credits, "usd": round(credits / 100000, 5),
       "duration_ms": data.get("duration_ms"), "session": data.get("session_id")}
os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
with open(LEDGER, "a") as f:
    f.write(json.dumps(row) + "\n")
sys.exit(0)
07 — Section

Recipe 4: SessionStart X context and a Stop hook that posts a ship note

Pull fresh X context at startup. A SessionStart hook with matcher startup can run one search for your project's name or handle and print the top posts; plain-text stdout from SessionStart lands in Claude's context before your first prompt. Two pages cost at most $0.006. mcp_tool hooks do not run on SessionStart at launch (MCP servers are not connected yet), so this one is a command hook calling the REST endpoint /twitter/tweet/advanced_search directly; the full script is the code example at the end of this page. Keep it fast and fail silent: a hook that errors here only shows a notice, but a slow one delays every session.

Post a ship note on Stop, carefully. Stop fires at the end of every turn, not only when a task is done, so a hook that posts on every Stop would post constantly. The script below acts only when Claude's last_assistant_message contains a line starting with #shipped (ask for that line in CLAUDE.md), skips when stop_hook_active is true, and defaults to a dry run that just shows a systemMessage. Run it with "async": true so it never holds up the session; note that in -p mode Claude Code cancels async hooks still running at teardown.

What the write call means. Posting runs through an authenticated session: one /twitter/user_login_v2 login ($0.10, with a proxy you supply) returns login_cookies, and each /twitter/create_tweet_v2 call is then 60 credits ($0.0006), billed per HTTP 200 call. The call acts as that X account, so X can rate-limit, lock or suspend it; keep a human pace (one note per real release, not per turn) and expect the session to need a fresh login from time to time. Reads, by contrast, need no X account at all.

Dry-run test. Piping a Stop payload whose last message ends with #shipped: v0.4 adds CSV export to the mentions CLI printed {"systemMessage": "[dry run] would post to X: v0.4 adds CSV export to the mentions CLI"}; a payload without the tag, or with stop_hook_active: true, printed nothing and exited 0.

python
#!/usr/bin/env python3
"""Stop hook: post a one-line ship note to X when a turn ends with a #shipped line.

Stop fires at the end of EVERY turn, so this hook only acts when Claude's last
message contains a line starting with "#shipped" (ask for it in CLAUDE.md).
Default is a dry run; set X_POST_ON_STOP=1 to really post.

Writes act as the logged-in X account: X can rate-limit, lock or suspend it, so
keep a human pace. One /twitter/create_tweet_v2 call is 60 credits ($0.0006),
billed per HTTP 200 call. login_cookies come from one /twitter/user_login_v2
login ($0.10, with a proxy you supply) and can need a fresh login later.
"""
import json, os, sys

data = json.load(sys.stdin)
if data.get("stop_hook_active"):
    sys.exit(0)                                   # never loop

msg = data.get("last_assistant_message") or ""
ship = [l.strip() for l in msg.splitlines() if l.strip().lower().startswith("#shipped")]
if not ship:
    sys.exit(0)
text = ship[0][len("#shipped"):].strip(" :-")[:270]
if not text:
    sys.exit(0)

if os.environ.get("X_POST_ON_STOP") != "1":
    print(json.dumps({"systemMessage": f"[dry run] would post to X: {text}"}))
    sys.exit(0)

import requests
r = requests.post(
    "https://api.twitterapi.io/twitter/create_tweet_v2",
    headers={"X-API-Key": os.environ["TWITTERAPI_IO_API_KEY"]},
    json={"login_cookies": os.environ["X_LOGIN_COOKIES"],
          "tweet_text": text,
          "proxy": os.environ["X_PROXY"]},
    timeout=20,
)
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
note = f"X post {body.get('status', r.status_code)}: tweet_id {body.get('tweet_id', '-')}"
print(json.dumps({"systemMessage": note}))
sys.exit(0)

# Register (async so it never blocks the turn):
# {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "python3",
#    "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/stop_post_summary.py"], "async": true}]}]}}
08 — Section

What X posts say about Claude Code hooks (how we collected this)

Question. When developers post about Claude Code hooks on X, which events do they name and what do they use hooks for? Query. Three keyword searches through twitterapi.io /twitter/tweet/advanced_search with retweets excluded: "claude code" hooks (Latest, two windows) and "claude code" hook (Top), plus a separate search seeded with four event names. Sample. 1,335 tweets returned in 74 requests, 1,153 unique; the keyword set used for the event shares is 927 posts, collected in 2026. Method. Event names from the official list matched case-sensitively as whole words; Stop, Notification, Setup and Elicitation counted only with hook context ("Stop hook", quoted or in backticks) because they are ordinary English words. Use-case families are keyword regexes over the text and overlap.

Measure (keyword set)Value
Posts about Claude Code hooks927
Name at least one documented event10.2% (95 posts)
Mention hooks alongside skills, subagents or plugins41.2%
Talk about blocking, guards or security24.5%
Talk about formatting or linting7.4%
Link a GitHub repo15.2%
Carry media (screenshots, video)35.9%
Median likes / 90th percentile2 / 124
Median views / 90th percentile177 / 15,605
LanguagesEnglish 68.6%, Chinese 11.7%, Japanese 5.7%

What it shows. Hooks are mostly mentioned as part of a kit: 41.2% of posts list them next to skills, subagents or plugins, and only 10.2% get specific enough to name an event. When people do get specific, it is about control rather than convenience: PreToolUse dominates, guard and security vocabulary (24.5%) far outruns formatting (7.4%), even though formatting is the first recipe in most tutorials. In the search seeded with event names (300 posts), PreToolUse again appears in 46.7% of posts. Engagement is very skewed: the median post gets 2 likes while the top tenth gets 124 or more.

What the sample cannot show. Search returns what X indexes for these words, so posts that describe a hook without the word "hook" are missed, and the Top query reaches further back than the Latest windows. Keyword families are crude ("context" also matches posts about context windows). Mentions are not usage: nothing here says how many people run a given hook. Privacy. Keyword searches return posts by private individuals, so raw pages stayed on our machine and were not published; the study files hold aggregates and de-identified rows only, and no person is named or profiled. Cost. 1,335 tweets at 15 credits each is 20,025 credits, $0.20.

Horizontal bar chart of what 927 X posts about Claude Code hooks talk about, by keyword family (families overlap): listed with skills or subagents 41.2%, context or memory 25.8%, block, guard or security 24.5%, MCP 18.1%, tests or verification 16.1%, notify or sound 12.8%, log, audit or observe 12.6%, format or lint 7.4%. Subtitle: 15.2% link a GitHub repo.
What X posts about Claude Code hooks talk about, by keyword family, collected in 2026. Guards and context injection come up far more often than formatters.
09 — Section

Troubleshooting: hook not firing, JSON ignored, Stop loops

The hook never runs. Open /hooks and confirm it is listed under the right event. Check the matcher's case and form (an MCP matcher without .* matches nothing; bash is not Bash). In an interactive session, hooks from every settings file wait until you accept the workspace trust dialog for that folder. If the menu says only managed hooks run, an administrator set allowManagedHooksOnly.

A hook error notice appears. Failed with non-blocking status code means the exit code was neither 0 nor 2. A No such file or directory in the notice means the script never started: fix the path, use ${CLAUDE_PROJECT_DIR} in exec form, and chmod +x the script. jq: command not found means jq is missing from the hook's PATH; write the hook in Python or Node instead.

JSON has no effect. Something else reached stdout first, often an echo in ~/.zshrc or ~/.bashrc; guard it with an interactive-shell check. Or a field sits at the wrong level: permissionDecision belongs inside hookSpecificOutput with hookEventName, while decision and reason for Stop or PostToolUse are top-level. Build output with jq -n or json.dumps, never string concatenation.

Claude will not stop. A Stop hook that blocks on a condition Claude cannot meet loops until the eight-continuation cap ends the turn with a warning. Exit 0 early when stop_hook_active is true.

Turning hooks off. Set "disableAllHooks": true in a settings file, or pass --settings '{"disableAllHooks": true}' for one run; there is no switch for a single hook short of deleting its entry, and user-level settings cannot disable managed hooks. Before scripting claude -p over a repository you did not write, read its .claude/ settings: in -p and SDK sessions there is no trust dialog, so committed project hooks run.

Security. Command hooks run with your full user permissions. The docs' own checklist: validate inputs, quote every shell variable, reject .. in paths, use absolute paths, and skip .env, .git/ and key files. If a hook needs secrets (like the X API key above), read them from the environment, never from the repo.

python
"""x_context_hook.py - a SessionStart hook that gives Claude the latest X posts
about your project, and doubles as a standalone script you can run by hand.

    pip install requests
    export TWITTERAPI_IO_API_KEY=...            # twitterapi.io dashboard
    export X_PROJECT_QUERY='"my-cli" OR @mycli -filter:retweets'

Register in .claude/settings.json:
    {"hooks": {"SessionStart": [{"matcher": "startup", "hooks": [
        {"type": "command", "command": "python3",
         "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/x_context_hook.py"], "timeout": 20}]}]}}

Plain-text stdout from a SessionStart hook is added to Claude's context.
Bill: 15 credits per tweet returned, so a full 20-tweet page is $0.003 and two pages $0.006.
"""
import json
import os
import sys

import requests

BASE = "https://api.twitterapi.io"
KEY = os.environ.get("TWITTERAPI_IO_API_KEY")
QUERY = os.environ.get("X_PROJECT_QUERY")
MAX_PAGES = int(os.environ.get("X_CONTEXT_PAGES", "2"))


def read_stdin_event():
    """Claude Code sends the hook input on stdin; by hand there is none."""
    if sys.stdin.isatty():
        return {"hook_event_name": "manual", "source": "startup"}
    try:
        return json.load(sys.stdin)
    except ValueError:
        return {}


def search(query, max_pages):
    tweets, cursor = [], ""
    for _ in range(max_pages):
        r = requests.get(
            f"{BASE}/twitter/tweet/advanced_search",
            headers={"X-API-Key": KEY},
            params={"query": query, "queryType": "Latest", "cursor": cursor},
            timeout=8,
        )
        r.raise_for_status()
        body = r.json()
        tweets.extend(body.get("tweets", []))
        if not body.get("has_next_page") or not body.get("next_cursor"):
            break
        cursor = body["next_cursor"]
    return tweets


def main():
    event = read_stdin_event()
    if event.get("source") not in (None, "startup", "resume"):
        return 0                       # skip /clear and compaction re-runs
    if not KEY or not QUERY:
        return 0                       # not configured: say nothing
    try:
        tweets = search(QUERY, MAX_PAGES)
    except requests.RequestException as exc:
        print(f"x_context_hook skipped: {exc}", file=sys.stderr)
        return 0                       # never break session start
    if not tweets:
        return 0
    tweets.sort(key=lambda t: t.get("likeCount") or 0, reverse=True)
    credits = max(15, 15 * len(tweets))
    lines = [f"X posts matching {QUERY}: {len(tweets)} returned "
             f"(about {credits} credits, ${credits / 100000:.4f}). Most liked first:"]
    for t in tweets[:10]:
        text = " ".join((t.get("text") or "").split())[:220]
        author = (t.get("author") or {}).get("userName", "?")
        lines.append(f"- @{author}, {t.get('likeCount', 0)} likes, "
                     f"{t.get('replyCount', 0)} replies, {t.get('createdAt')}: {text}")
    print("\n".join(lines))
    return 0


if __name__ == "__main__":
    sys.exit(main())
10 — Questions

Questions readers ask

What are Claude Code hooks?

Handlers that Claude Code runs automatically at fixed lifecycle points, such as before a tool call (PreToolUse), after it (PostToolUse), at the end of a turn (Stop) or when a session starts (SessionStart). They are configured in settings files and can be shell commands, HTTP endpoints, MCP tool calls, LLM prompts or subagents. Unlike a CLAUDE.md instruction, a hook runs every time, whatever the model decides.

Where do I put Claude Code hooks?

Under a hooks key in ~/.claude/settings.json (all projects), .claude/settings.json (one project, committed), or .claude/settings.local.json (one project, not committed). Plugins use hooks/hooks.json, and skills and subagents can declare hooks in frontmatter. Entries from all levels merge; /hooks shows what is active and where it came from.

How many hook events does Claude Code have?

The hooks reference lists 33, including SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, SubagentStart, SubagentStop, Stop, StopFailure, PreCompact, PostCompact, FileChanged, PreModelSwitch and SessionEnd. Older tutorials list far fewer, so check the reference before relying on one.

How do I block a tool call with a hook?

Register a PreToolUse hook on the tool and either exit 2 with the reason on stderr, or exit 0 and print {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..."}}. Exit 1 does not block. Add "onFailure": "block" so a crashed or missing script also blocks. A hook deny holds even with --dangerously-skip-permissions.

How do I match MCP tools in a hook?

MCP tools are named mcp__<server>__<tool>. Use mcp__<server>__.* to match every tool on one server; the .* matters, because mcp__<server> alone contains only exact-match characters and matches no tool. For example, mcp__twitterapi-mcp__.* covers all 12 tools of the twitterapi.io MCP server when you add it under that name.

Why does my Stop hook keep Claude running forever?

A Stop hook that exits 2 or returns decision: "block" tells Claude to keep working. If the condition never clears, it loops until Claude Code's cap of eight consecutive continuations ends the turn. Read stop_hook_active from stdin and exit 0 when it is true. Stop also fires on every turn, not only when a task is finished.

Can a hook send Claude Code notifications to my desktop or phone?

Yes. A Notification hook (matchers such as permission_prompt or idle_prompt) can return a terminalSequence like OSC 777 or OSC 9 for a desktop notification, since hooks cannot write to /dev/tty, or call any external service such as a chat webhook. Notification hooks cannot block or change the notification itself.

Can hooks control what an agent spends on X (Twitter) data?

Yes. Because MCP tools go through PreToolUse and PostToolUse, a PostToolUse hook can log each X call and its credits, and a PreToolUse hook can ask or deny once a daily budget is reached. On twitterapi.io a full 20-tweet page is $0.003 ($0.15 per 1,000 tweets), against $0.005 per post read on X's pay-per-usage rate card.

11 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key

Already building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.