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¶
You work across projects with different toolchains — Node versions, Python environments, Nix shells. With Claude Code you have two options: pre-configure the environment before the session, or prompt the agent to reload it after each directory change. Neither is reliable. Pre-configuration is fragile, and prompts are easy to forget.
Claude Code v2.1.83 added two state-change hook events to fix this. CwdChanged fires when the agent's working directory changes. FileChanged fires when a watched file changes on disk. Both are observational — they cannot block execution. But both can write to CLAUDE_ENV_FILE, the same environment persistence mechanism SessionStart uses. That lets a hook pass shell variables to every later Bash tool call.
CwdChanged¶
Fires after every working directory change, for example when 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 holds the new directory path. Use it to detect which project the agent has entered, then load the matching environment.
FileChanged¶
Fires when a watched file is created, modified, or deleted. The matcher field lists which filenames to watch, as pipe-separated literal names. These are not regex patterns, unlike PreToolUse and 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. Write KEY=value lines to this file and the variables persist into the agent's later Bash calls — the same mechanism SessionStart hooks use. Without it, environment changes made inside a hook script never 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. This is the main 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 reloads that directory's .envrc automatically — the 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 does nothing when direnv is absent. On Windows dev containers or minimal CI images, you need a fallback or a different environment loader.
- Malformed
.envrc:direnv allowsucceeds, butdirenv export bashfails on syntax errors in.envrc. The hook exits 0,CLAUDE_ENV_FILEreceives no writes, and the agent proceeds with a stale environment. No error surfaces. - CwdChanged fires unconditionally: every
cdtriggers the hook, including directory changes inside a single task. On repos with many subdirectories, this adds latency to each directory change. - Observational constraint: hooks cannot block the agent. If environment loading fails, the next Bash call runs in the wrong environment, with no signal to the agent.
- FileChanged is blind to Bash-driven edits:
FileChangedonly fires for changes made by Claude Code's Edit and Write tools, not for files changed through the Bash tool (claude-code#44925). If the agent runsmise use, appends to.envrcwith a shell redirect, or otherwise edits a watched file through Bash, the hook does not fire and the environment stays stale. Trigger a reload yourself, or rely onCwdChangedinstead.
Key Takeaways¶
CwdChangedfires on every directory change — no matcher, no blocking, observational onlyFileChangedmatches literal filenames (pipe-separated); use it to reload config when tracked files change- Write to
CLAUDE_ENV_FILEto 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