← Back to posts

Injecting Context with Hooks (The Part I Wish I Had Known Earlier)

claude-codehooksautomationcontext

I kept running into the same annoying pattern. I’d start a Claude Code session, ask it to help debug something, and it would immediately ask: “What’s your current git branch? Any recent errors? Which environment are you in?”

Every. Single. Time.

Then I’d copy-paste my git status, tail my error logs, and we’d finally get to work. Felt like showing my ID at the door every day even though the bouncer knows me.

Turns out hooks can just… inject that context automatically. No copying, no pasting, no repetitive Q&A. Claude starts every session already knowing what it needs to know.

What Context Injection Actually Is

In my intro hooks post, I mentioned you could return JSON to modify Claude’s behavior. I didn’t go deep on it because honestly, I didn’t get how useful it was.

Here’s the simple version: hooks can feed text directly into Claude’s conversation. When your hook runs, it can say “hey, before you start thinking, here’s some information you should know.”

It’s like handing someone a briefing document before a meeting instead of making them ask for it.

How It Works

Simplest version: your hook prints text, Claude sees it.

#!/bin/bash
echo "Current branch: $(git rev-parse --abbrev-ref HEAD)"
echo "Uncommitted changes: $(git status --short | wc -l)"

You can also return JSON if you need more control (like combining context injection with blocking operations or modifying tool inputs). I’ll show both.

SessionStart: Loading Context Before Anything Happens

The most useful place for context injection is SessionStart. It fires when you start a Claude Code session, before Claude does anything.

I use this to give Claude the lay of the land:

#!/bin/bash
# .claude/hooks/session-start.sh

cat <<EOF
## Project State

Branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "N/A")
Changes: $(git status --short 2>/dev/null | wc -l) files modified

## Recent Commits
$(git log --oneline -5 2>/dev/null)

## Environment
Database: ${DATABASE_URL:-"Not set"}
Debug mode: ${DEBUG:-"false"}
EOF

Now when I start a session and say “help me debug the authentication issue,” Claude already knows I’m on the feature/oauth branch with 7 uncommitted files. It doesn’t have to ask.

UserPromptSubmit: Context That Updates Every Time You Ask Something

UserPromptSubmit hooks run every time you send a message. That’s where I inject dynamic context—stuff that changes between prompts.

Like recent errors:

#!/bin/bash
# Check if there are new errors in the log

ERROR_LOG="./logs/error.log"

if [ ! -f "$ERROR_LOG" ]; then
  exit 0  # No log file, nothing to inject
fi

# Get errors from the last 5 minutes
RECENT_ERRORS=$(find "$ERROR_LOG" -mmin -5 -exec tail -10 {} \; 2>/dev/null)

if [ -n "$RECENT_ERRORS" ]; then
  echo "## Recent Errors (last 5 minutes)"
  echo "$RECENT_ERRORS"
fi

This runs before Claude processes your prompt. If there are fresh errors, Claude sees them automatically. If not, the hook exits quietly and nothing happens.

I’ve caught issues this way that I didn’t even realize were happening.

PreToolUse: Modifying What Claude’s About To Do

This one’s sneaky. PreToolUse hooks can intercept a tool call and change the input before it runs.

Example: I never want database migrations to run without the --check flag. Ever. But I also don’t want Claude to have to remember that rule—I want it enforced silently.

#!/bin/bash
INPUT=$(cat)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Only intercept database migration commands
if [[ "$TOOL_NAME" == "Bash" ]] && [[ "$COMMAND" == *"alembic upgrade"* ]]; then
  # Add --check flag if not present
  if [[ "$COMMAND" != *"--check"* ]]; then
    MODIFIED_COMMAND="${COMMAND} --check"

    cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "Added --check flag for safety"
  },
  "updatedInput": {
    "command": "$MODIFIED_COMMAND"
  }
}
EOF
    exit 0
  fi
fi

# Not a migration command, allow as-is
exit 0

Claude tries to run alembic upgrade head, the hook silently changes it to alembic upgrade head --check, and Claude never knows the difference. The command just works safely.

This pattern eliminates the “block and ask Claude to rephrase” loop. Faster, cleaner, less annoying.

Things That Tripped Me Up

Too much context is worse than too little. I tried injecting my entire git log once. Filled half Claude’s context window before I even asked a question. Now I keep it under 1,000 characters per hook.

Hooks are cached at startup. If you change a hook mid-session, restart Claude Code for it to take effect. Cost me 20 minutes of debugging once.

JSON escaping breaks everything. Use jq to escape strings: ESCAPED=$(echo "$CONTEXT" | jq -Rs '.'). Saves headaches.

Getting Started

Start with one SessionStart hook. Inject git status and recent commits. That alone will make sessions feel smoother.

Then add a UserPromptSubmit hook if you have log files. Inject the last 10 errors or recent API failures.

Keep it simple. You can always add more later.