# Hooks
Hooks let you run custom logic around each tool call—before permission gating and after execution—without changing Phi’s binary or putting settings into `config.yaml`.
Use hooks when you need organization policy, audit trails, or input rewriting that the permission Gate does not cover.
| Audience | This document |
| --- | --- |
| Hook authors | Create and test scripts under `.phi/hooks/` |
| Operators | Deploy user- or project-level policy |
| Contributors | See [Related code](#related-code) |
---
## Concepts
### Execution order
```text
emit(InProgress)
→ PreTool hooks (allow | deny | modify)
→ Gate (Ask UI / permission rules)
→ tool.Run
→ PostTool hooks (optional context / output rewrite)
→ emit(Done | …)
```
- **PreTool** runs before Gate. A deny can stop a tool without user approval.
- **PostTool** can append model-facing `context` and/or rewrite the tool `output`.
- `context` is wrapped in `…` on the tool result sent to the model only. TUI Detail/Output are unchanged by `context`. If no hook returns `context`, the tags are omitted.
- `output` replaces both the model-facing tool content and the TUI Output string for that tool run (Detail is unchanged). Omit `output` (or leave it empty) to keep the original tool result.
- If no hooks are loaded, behavior matches a build with hooks disabled.
### Discovery model
Each hook is a directory containing a required `hook.json` and an executable referenced by `run`.
| Scope | Path | Precedence |
| --- | --- | --- |
| User | `~/.phi/hooks//` | Lower |
| Project | `/.phi/hooks//` | Higher — same `` replaces the user hook entirely |
- Phi creates an empty `~/.phi/hooks/` on startup if needed.
- Directories without a valid `hook.json` are skipped. Parse errors produce warnings and do not block startup.
- Set `PHI_HOOKS=off` to disable discovery and execution entirely.
---
## Getting started
### 1. Create a project hook
```text
.phi/hooks/guard-bash/
hook.json
run.sh
```
**`hook.json`**
```json
{
"name": "guard-bash",
"event": "pre_tool",
"match": "bash",
"run": "./run.sh",
"timeout": "5s",
"fail_closed": true
}
```
**`run.sh`** (must be executable: `chmod +x run.sh`)
```bash
#!/usr/bin/env bash
# Deny bash commands whose text contains "phi-deny".
input=$(cat)
case "$input" in
*phi-deny*)
echo '{"action":"deny","reason":"blocked by guard-bash (matched phi-deny)"}'
exit 2
;;
esac
echo '{"action":"allow"}'
```
### 2. Load hooks
- Restart Phi, or
- Command palette: **hooks → reload** (`Ctrl+K`)
List loaded hooks with **hooks → list**.
### 3. Verify
Ask the agent to run `echo phi-deny`. The PreTool hook should deny the call.
---
## Authoring guide
### Manifest (`hook.json`)
| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | string | no | directory name | Unique id; used for user/project override |
| `event` | string | yes | — | `pre_tool` or `post_tool` |
| `match` | string | no | `*` | Exact tool name, or `*` for all tools. Not a regex. |
| `run` | string | yes | — | Executable path relative to the hook directory, or absolute. Executed directly (no shell). |
| `timeout` | string \| number | no | `5s` | Go duration string (e.g. `"5s"`) or seconds as a number. Maximum `60s`. |
| `fail_closed` | boolean | no | `false` | On failure, deny (Pre) / stop (Post) instead of ignoring |
| `async` | boolean | no | `false` | `post_tool` only: fire-and-forget; result ignored |
| `disabled` | boolean | no | `false` | Skip loading this hook |
### PreTool response
Write one JSON object on stdout (first line only). Empty stdout with exit `0` means allow.
```json
{ "action": "allow" }
{ "action": "deny", "reason": "policy violation" }
{ "action": "modify", "input": { "command": "echo safe" } }
```
| Exit code | Behavior |
| --- | --- |
| `0` | Parse stdout; empty body → allow |
| `2` | Hard deny (even with empty body) |
| other | Treated as hook error → fail-open skip, or deny if `fail_closed` |
Optional fields on success: `reason`, `context` (model-facing note).
### PostTool response
```json
{ "context": "note for the model", "output": "rewritten tool result", "stop": false, "reason": "" }
```
| Field | Effect |
| --- | --- |
| `context` | Model-only note (see Concepts). Aggregated from matching sync hooks (joined; capped at 4 KiB). |
| `output` | Rewrites tool result for the model **and** TUI Output. Among sync hooks that set it, the last matching hook in entry order wins (execution is parallel, but the merge is deterministic) — prefer one rewrite hook. Not subject to the 4 KiB context cap. |
| `stop` / `reason` | Reserved stop signal (not yet wired into the agent loop). |
`async: true` hooks are fire-and-forget: their stdout is ignored, so they cannot contribute `context` or `output`.
| Exit code | Behavior |
| --- | --- |
| `0` | Parse stdout; empty body → no-op |
| `2` | Treated as stop request |
| other | Hook error → fail-open skip, or stop if `fail_closed` |
### Failure policy (`fail_closed`)
| Value | When the script crashes, times out, or returns invalid JSON |
| --- | --- |
| `false` (default) | Ignore that hook (suitable for audit) |
| `true` | Deny (Pre) or stop (Post) (suitable for security gates) |
In `permissions.mode: readonly`, only hooks with `fail_closed: true` run, so slow audit hooks do not stall exploratory tool use. Interactive sessions and `phi run` run all loaded hooks.
### Ordering and concurrency
- Matching **PreTool** hooks run **serially**. First deny wins; modify results chain onto `input`.
- Matching **PostTool** hooks run **in parallel** (except `async`, which is detached).
- Order across multiple hooks is **not** guaranteed. If order matters, put the logic in one hook.
- Because PostTool runs in parallel, do not rely on several hooks each rewriting `output` for the same tool call; put rewrite logic in one sync hook.
---
## Protocol reference
External hooks use a single JSON line on stdin and a single JSON line on stdout. Working directory is the hook directory. stdout/stderr are capped at **1 MiB** each. Aggregated model context from hooks is capped at **4 KiB**.
### Request (stdin)
```json
{
"session_id": "…",
"cwd": "/path/to/project",
"hook_event": "pre_tool",
"tool": "bash",
"tool_use_id": "call_…",
"input": { "command": "ls" }
}
```
| Field | PreTool | PostTool |
| --- | --- | --- |
| `session_id` | yes | yes |
| `cwd` | yes | yes |
| `hook_event` | `pre_tool` | `post_tool` |
| `tool` | yes | yes |
| `tool_use_id` | yes | yes |
| `input` | yes | yes |
| `output` | — | tool stdout / result text when present |
| `error` | — | tool error text; empty on success |
### Environment
Sensitive parent environment keys are stripped before spawn (substring match, case-insensitive), including patterns such as `API_KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `PHI_API_KEY`, and common cloud credential names.
Injected variables:
| Variable | Value |
| --- | --- |
| `PHI_HOOK_EVENT` | `pre_tool` or `post_tool` |
| `PHI_SESSION_ID` | Session id |
| `PHI_CWD` | Workspace cwd |
| `PHI_PROJECT_DIR` | Same as cwd for command hooks |
---
## Operations
| Action | How |
| --- | --- |
| Disable all hooks | `PHI_HOOKS=off` |
| Inspect load warnings | `PHI_DEBUG=1` |
| List / reload in TUI | `Ctrl+K` → **hooks → list** / **hooks → reload** |
| Override a user hook | Place a directory with the same `name` under `/.phi/hooks/` |
Configuration for hooks is **not** stored in `~/.phi/config.yaml` or managed via `phi config`.
---
## Limitations
The following are intentionally out of scope:
- Long-lived plugin host processes or bidirectional RPC
- File-watch based hot reload (use palette reload or restart)
- Registering new tools from hooks (use `tooldef.Tool`)
- Mixing hook definitions into the main YAML config
---
## Related code
| Path | Role |
| --- | --- |
| `internal/hooks/` | Types, Manager, discovery, CommandHook, Load |
| `internal/agent/executor.go` | Pre → Gate → Run → Post |
| `internal/project` | `HooksDir()`, directory bootstrap |
| `internal/tui` | Engine wiring; list / reload commands |