Skip to content

Reactive Environment Hooks: CwdChanged and FileChanged

CwdChanged and FileChanged hooks let the agent trigger shell-level side effects in response to directory changes and file modifications — the same trigger model that direnv and similar tools use — without requiring a prompt.

The Problem

Developers using Claude Code across projects with different toolchains — Node versions, Python environments, Nix shells — must either pre-configure the environment before the session or manually prompt the agent to reload it after a directory change. Neither option is reliable: pre-configuration is fragile, and prompts are easy to forget.

Claude Code v2.1.83 added two state-change hook events that address this: CwdChanged fires when the agent's working directory changes; FileChanged fires when a watched file is modified on disk. Both are observational — they cannot block execution — but they have access to CLAUDE_ENV_FILE, the same environment persistence mechanism used by SessionStart, which lets hooks propagate shell variables to all subsequent Bash tool calls.

CwdChanged

Fires after every working directory change (e.g., after the agent runs cd). No matcher is supported — it fires unconditionally.

Input payload:

{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/my-project/src",
  "hook_event_name": "CwdChanged"
}

The cwd field contains the new directory path. Use it to detect which project the agent has entered and load the appropriate environment.

FileChanged

Fires when a watched file is created, modified, or deleted. The matcher field specifies which filenames to watch using pipe-separated literal names — not regex patterns, unlike PreToolUse/PostToolUse matchers.

Input payload:

{
  "session_id": "abc123",
  "transcript_path": "/Users/.../.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/my-project",
  "hook_event_name": "FileChanged",
  "file_path": "/Users/my-project/.env",
  "change_type": "modified"
}

change_type is one of "created", "modified", or "deleted".

Environment Persistence via CLAUDE_ENV_FILE

Both events expose the CLAUDE_ENV_FILE environment variable. Writing KEY=value lines to this file persists variables into the agent's subsequent Bash invocations — the same mechanism SessionStart hooks use. Without this, environment changes made inside a hook script don't reach Claude Code's tool execution context.

# Inside any CwdChanged or FileChanged hook:
export MY_VAR="value"
echo "MY_VAR=$MY_VAR" >> "$CLAUDE_ENV_FILE"

Example

Auto-load direnv whenever the agent changes directory. direnv reads .envrc files and exports environment variables scoped to that directory tree — the canonical use case for CwdChanged.

.claude/hooks/sync-direnv.sh:

#!/bin/bash

if command -v direnv &> /dev/null; then
  direnv allow
  if [ -n "$CLAUDE_ENV_FILE" ]; then
    eval "$(direnv export bash)" >> "$CLAUDE_ENV_FILE"
  fi
fi

exit 0

.claude/settings.json:

{
  "hooks": {
    "CwdChanged": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/sync-direnv.sh"
          }
        ]
      }
    ]
  }
}

With this in place, moving into any project subdirectory automatically reloads the directory's .envrc — Node version, Python virtualenv, AWS profile, or any other variable direnv manages.

For FileChanged, use the same CLAUDE_ENV_FILE pattern but scope the hook to specific config filenames:

{
  "hooks": {
    "FileChanged": [
      {
        "matcher": ".env|.envrc|.env.local",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/sync-direnv.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

When This Backfires

  • direnv not available: The hook silently no-ops when direnv is absent. On Windows dev containers or minimal CI images, you need a fallback or a different environment loader entirely.
  • Malformed .envrc: direnv allow succeeds but direnv export bash fails on syntax errors in .envrc. The hook exits 0, CLAUDE_ENV_FILE receives no writes, and the agent proceeds with a stale environment — no error surfaced.
  • CwdChanged fires unconditionally: Every cd invocation triggers the hook, including directory changes inside a single task. On repos with many subdirectories, this adds latency on each directory change.
  • Observational constraint: Hooks cannot block the agent from proceeding. If environment loading fails, the next Bash invocation runs in the wrong environment with no signal to the agent.
  • FileChanged is blind to Bash-driven edits: FileChanged only fires for changes made by Claude Code's Edit/Write tools — not for files modified via the Bash tool (claude-code#44925). If the agent runs mise use, appends to .envrc via a shell redirect, or otherwise edits a watched file through Bash, the hook does not fire and the environment remains stale. Trigger a reload explicitly or depend on CwdChanged instead.

Key Takeaways

  • CwdChanged fires on every directory change — no matcher, no blocking, observational only
  • FileChanged matches literal filenames (pipe-separated); use it to reload config when tracked files change
  • Write to CLAUDE_ENV_FILE to persist environment variables to subsequent Bash calls — without it, hook-level changes don't propagate
  • Both events compose with existing PreToolUse/PostToolUse hooks; they add a new trigger class (state-change) to the lifecycle
  • direnv is the canonical integration: one hook covers all toolchain switching for the entire repo tree
Feedback