# Reactive Hooks
Zeph can run shell commands automatically in response to environment changes and tool execution events. Four hook events are supported: working directory changes, file system changes, tool execution before/after.
## Hook Types
### `pre_tool_use` and `post_tool_use`
Fires before and after a tool is executed. Useful for logging, monitoring, security auditing, or modifying the environment before/after tool runs.
**Pre-execution (before tool runs):**
```toml
[[hooks.pre_tool_use]]
args = ["About to run: $ZEPH_TOOL_NAME with args: $ZEPH_TOOL_ARGS_JSON"]
```
**Post-execution (after tool runs):**
```toml
[[hooks.post_tool_use]]
tools = "write_file|edit_file" # File write tools
command = "git"
args = ["add", "$ZEPH_TOOL_NAME"]
fail_closed = false # If true, hook failure aborts the tool chain (default: false)
```
Environment variables available to hook processes:
| `ZEPH_TOOL_NAME` | pre + post | Tool name (e.g., `shell`, `web_scrape`) |
| `ZEPH_TOOL_ARGS_JSON` | pre + post | Tool arguments as JSON (truncated to 64 KiB via UTF-8 boundary) |
| `ZEPH_TOOL_DURATION_MS` | post only | Time taken to execute the tool (milliseconds) |
| `ZEPH_SESSION_ID` | pre + post (main agent only) | Session ID; omitted in subagent hooks |
**Hook firing order:**
Pre-hooks fire **before** utility gate and permission checks. This means observers can see all tool invocations, including those that would be blocked by policies. Post-hooks fire after successful execution.
### `cwd_changed`
Fires when the agent's working directory changes — either via the `set_working_directory` tool or an explicit directory change detected after tool execution.
```toml
[[hooks.cwd_changed]]
command = "echo"
args = ["Changed to $ZEPH_NEW_CWD"]
[[hooks.cwd_changed]]
command = "git"
args = ["status", "--short"]
```
Environment variables available to the hook process:
| `ZEPH_OLD_CWD` | Previous working directory |
| `ZEPH_NEW_CWD` | New working directory |
### `file_changed`
Fires when a file under `watch_paths` is modified. Changes are detected via `notify-debouncer-mini` with a 500 ms debounce window — rapid successive modifications produce a single event.
```toml
[hooks.file_changed]
watch_paths = ["src/", "config.toml"]
[[hooks.file_changed.handlers]]
command = "cargo"
args = ["check", "--quiet"]
[[hooks.file_changed.handlers]]
command = "echo"
args = ["File changed: $ZEPH_CHANGED_PATH"]
```
Environment variable available to the hook process:
| `ZEPH_CHANGED_PATH` | Absolute path of the changed file |
## The `set_working_directory` Tool
The `set_working_directory` tool gives the LLM an explicit, persistent way to change the agent's working directory. Unlike `cd` in a `bash` tool call (which is ephemeral and scoped to one subprocess), `set_working_directory` updates the agent's global cwd and triggers any `cwd_changed` hooks.
```text
Use set_working_directory to switch into /path/to/project
```
After the tool executes, subsequent `bash` and file tool calls run relative to the new directory.
## TUI Indicator
When a hook fires, the TUI status bar shows a short spinner message:
- `cwd_changed` → `Working directory changed…`
- `file_changed` → `File changed: <path>…`
The indicator disappears once all hook commands for that event have completed.
## Configuration Reference
```toml
# Pre-tool-use hooks — run before any tool execution
[[hooks.pre_tool_use]]
args = ["Running: $ZEPH_TOOL_NAME"]
fail_closed = false # If true, hook failure aborts the tool (default: false)
# Post-tool-use hooks — run after tool execution completes
[[hooks.post_tool_use]]
tools = "write_file"
command = "git"
args = ["add", "$ZEPH_TOOL_NAME"]
fail_closed = false # If true, hook failure blocks subsequent tools
# cwd_changed hooks — run in order when the working directory changes
[[hooks.cwd_changed]]
command = "echo"
args = ["cwd is now $ZEPH_NEW_CWD"]
# file_changed hooks — watch_paths + handler list
[hooks.file_changed]
watch_paths = ["src/", "tests/"] # relative or absolute paths to watch
debounce_ms = 500 # debounce window in milliseconds (default: 500)
[[hooks.file_changed.handlers]]
command = "cargo"
args = ["check", "--quiet"]
```
| `hooks.pre_tool_use[].tools` | `string` | — | Pipe-separated tool name patterns to match |
| `hooks.pre_tool_use[].command` | `string` | — | Executable to run |
| `hooks.pre_tool_use[].args` | `Vec<String>` | `[]` | Arguments (env vars expanded) |
| `hooks.pre_tool_use[].fail_closed` | `bool` | false | If true, hook failure aborts the tool chain |
| `hooks.post_tool_use[].tools` | `string` | — | Pipe-separated tool name patterns to match |
| `hooks.post_tool_use[].command` | `string` | — | Executable to run |
| `hooks.post_tool_use[].args` | `Vec<String>` | `[]` | Arguments (env vars expanded) |
| `hooks.post_tool_use[].fail_closed` | `bool` | false | If true, hook failure aborts the tool chain |
| `hooks.cwd_changed[].command` | `string` | — | Executable to run |
| `hooks.cwd_changed[].args` | `Vec<String>` | `[]` | Arguments (env vars expanded) |
| `hooks.file_changed.watch_paths` | `Vec<String>` | `[]` | Paths to monitor |
| `hooks.file_changed.debounce_ms` | `u64` | `500` | Debounce window in milliseconds |
| `hooks.file_changed.handlers[].command` | `string` | — | Executable to run |
| `hooks.file_changed.handlers[].args` | `Vec<String>` | `[]` | Arguments (env vars expanded) |
### Tool Pattern Matching
Tool name patterns support pipe-separated patterns and glob matching:
```toml
# Match exact tool names
tools = "shell" # Only the shell tool
# Match multiple tools
# Glob patterns (glob syntax)
tools = "write_*" # write_file, write_dir, etc.
# Combine exact and globs
Fires when a file under one of the configured `watch_paths` is modified. The watcher uses `notify-debouncer-mini` with a configurable debounce window (default: 500 ms), so rapid successive writes produce a single event.
The changed file path is passed to hook commands via:
| `ZEPH_CHANGED_PATH` | Absolute path of the modified file |
**Use cases:**
- Run `cargo check` on every save during a coding session
- Regenerate documentation when a source file changes
- Invalidate a cache or restart a development server
Configure glob patterns for `watch_paths` and add one or more handler commands:
```toml
[hooks.file_changed]
watch_paths = ["src/", "tests/", "Cargo.toml"]
debounce_ms = 300
[[hooks.file_changed.hooks]]
type = "command"
command = "cargo"
args = ["check", "--quiet"]
timeout_secs = 30
fail_closed = false
[[hooks.file_changed.hooks]]
type = "command"
command = "echo"
args = ["Changed: $ZEPH_CHANGED_PATH"]
timeout_secs = 5
fail_closed = false
```
`watch_paths` accepts relative paths (resolved from the agent's working directory at startup) or absolute paths. Directories are watched recursively.
### Hook Execution Model
Each hook definition (`HookDef`) carries:
| `type` | `string` | — | Always `"command"` |
| `command` | `string` | — | Executable to run (must be on `PATH` or an absolute path) |
| `args` | `Vec<String>` | `[]` | Arguments; `$VAR` references in args are expanded from the hook environment |
| `timeout_secs` | `u64` | `10` | Maximum time to wait for the command to complete |
| `fail_closed` | `bool` | `false` | When `true`, a hook failure blocks the agent turn; when `false`, failures are logged as warnings |
Multiple hooks for the same event are executed in declaration order. If `fail_closed = true` on any hook, a failure in that hook stops execution of subsequent hooks for that event.
### `TurnComplete`
Fires after each agent turn completes. This hook does not block the turn — it runs fire-and-forget in the background and allows notification integrations, logging, or external system updates to happen after the agent responds.
Hook commands receive environment variables describing the turn outcome:
| `ZEPH_TURN_DURATION_MS` | Turn latency in milliseconds |
| `ZEPH_TURN_STATUS` | `success`, `error`, or `cancelled` |
| `ZEPH_TURN_PREVIEW` | First 150 chars of redacted agent response |
| `ZEPH_TURN_LLM_REQUESTS` | Number of LLM API calls made this turn |
**Use cases:**
- Send a custom notification via a webhook
- Log turn metrics to an external service
- Sync agent state to an external system after each turn
```toml
[[hooks.turn_complete]]
type = "command"
command = "curl"
args = ["-X", "POST", "http://localhost:9999/webhook", "-d", "status=$ZEPH_TURN_STATUS"]
timeout_secs = 5
fail_closed = false
```
When a `[notifications]` block is configured, `turn_complete` hooks share the same `should_fire` gate — the hook only runs if notifications are also configured to fire. When `[notifications]` is absent or `enabled = false`, `turn_complete` hooks fire on every turn.
### `PermissionDenied`
Fires when a tool execution is blocked by any gate check: policy gates, sandbox restrictions, permission layers, rate limiters, quota limits, utility action restrictions, or dependency failures. This comprehensive hook allows you to log or audit all blocked tool calls before they reach the user or external systems.
Hook commands receive:
| `ZEPH_DENIED_TOOL` | Name of the blocked tool |
| `ZEPH_DENY_REASON` | Reason the tool was denied (e.g., `"quota exceeded"`, `"policy gate: untrusted_model"`, `"utility action: ModelSwitch"`) |
**Denial reasons include:**
- `quota exceeded` — tool execution quota exhausted
- `policy gate: <name>` — blocked by a named policy gate
- `sandbox violation: <type>` — sandbox restriction violated
- `rate limit exceeded` — API rate limit hit
- `dependency failed` — dependent tool or resource unavailable
- `utility action: <action>` — blocked by a utility gate (e.g., `ModelSwitch`, `ConfigReload`)
- `blocked by before_tool layer` — pre-execution permission check
**Use cases:**
- Log security audit events to a central system
- Alert on suspicious tool invocation patterns
- Track which policies are enforcing restrictions
- Monitor quota exhaustion
```toml
[[hooks.permission_denied]]
type = "command"
command = "logger"
args = ["-t", "zeph-security", "Denied tool: $ZEPH_DENIED_TOOL - $ZEPH_DENY_REASON"]
timeout_secs = 5
fail_closed = false
```
## MCP Tool Hooks
Hooks support direct MCP tool invocation via `type = "mcp_tool"`. When `type = "mcp_tool"`, the hook invokes a tool on a connected MCP server instead of spawning a subprocess.
```toml
[[hooks.cwd_changed]]
type = "mcp_tool"
server = "filesystem" # MCP server id
tool = "write_file" # MCP tool name
args = {"path": "/tmp/log", "contents": "Changed to $ZEPH_NEW_CWD"}
fail_closed = false # ignored if server unavailable
```
MCP tool hooks require the MCP manager to be active. If the server is unavailable, the hook result depends on `fail_closed`:
- `fail_closed = false` (default): error is logged and the turn continues
- `fail_closed = true`: turn is blocked until the tool succeeds or timeout expires