<!-- Markdown mirror. Canonical: https://empryo.com/docs/tools/hooks -->

# Hooks

> Run shell commands on agent lifecycle events: before/after tool calls, session start/stop, compaction. Wire-compatible with Claude Code settings.json hooks.

Hooks fire shell commands at lifecycle events - before/after tool calls, on session start/stop, around compaction. Wire-compatible with Claude Code, so your existing `.claude/settings.json` hooks work as-is.

## Quick example

Auto-format every TypeScript file the agent edits:

```json .empryo/config.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "prettier --write $(echo $HOOK_TOOL_INPUT | jq -r '.path // empty')",
            "async": true
          }
        ]
      }
    ]
  }
}
```

`/hooks` inside Empryo lists active hooks and toggles them per session.

## Events

| Event | Fires | Matches |
|-------|-------|---------|
| `PreToolUse` | Before a tool runs | Tool name |
| `PostToolUse` | After a tool succeeds | Tool name |
| `PostToolUseFailure` | After a tool fails | Tool name |
| `UserPromptSubmit` | On user message | - |
| `Stop` / `StopFailure` | Turn ends | - |
| `SessionStart` / `SessionEnd` | Session boundary | - |
| `PreCompact` / `PostCompact` | Around compaction | - |
| `SubagentStart` / `SubagentStop` | Spark/ember spawn/exit | - |
| `Notification` | System notifications | - |

## Rule schema

```json
{
  "matcher": "Bash|Edit",       // tool name, pipe-separated or regex
  "hooks": [
    {
      "type": "command",
      "command": "my-hook.sh",
      "async": false,             // run in background
      "timeout": 10,              // seconds (default 10)
      "once": false,              // fire once per session
      "if": "Bash(git *)"        // extra glob filter
    }
  ]
}
```

The `if` field narrows by first string arg: `Bash(rm *)`, `Edit(*.ts)`, etc. `*` matches any sequence, `?` one char.

Tool names use Claude Code conventions (`Bash`, `Edit`, `Write`, `Read`, `Grep`, `Glob`, `WebSearch`, `Agent`). Empryo maps its internal names automatically.

## Events, and where each one fires

| Event | TUI | Desktop | Headless |
|---|---|---|---|
| `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | yes | yes | yes |
| `UserPromptSubmit`, `Stop`, `StopFailure` | yes | yes | no |
| `PreCompact`, `PostCompact` | yes | yes | no |
| `SessionStart` | yes | no | no |
| `SessionEnd` | yes | no | no |
| `SubagentStart`, `SubagentStop` | yes | no | no |
| `Notification` | no | no | no |

Tool events fire on every surface. `SessionEnd` is the exception that has to be
quick: Empryo is already shutting down while it runs, so each hook gets 3
seconds.

## Config sources (merged in order)

1. Claude Code managed policy settings, `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS), `/etc/claude-code/managed-settings.json` (Linux), `%ProgramData%\ClaudeCode\managed-settings.json` (Windows)
2. `~/.claude/settings.json`
3. `.claude/settings.json`
4. `.claude/settings.local.json`
5. `~/.empryo/config.json` and `~/.empryo/hooks.json`
6. `.empryo/config.json` and `.empryo/hooks.json`

Set `"disableAllHooks": true` in any file to kill all hooks. Managed policy hooks
are the exception: an administrator's hooks keep running, which is what an
organization shipping an audit hook through MDM relies on.

### Hooks you wrote for another agent

Empryo also reads the files Copilot CLI and Codex keep their hooks in, so a hook
you set up once fires everywhere. Nothing is copied or rewritten on disk: the
files are read where they already live and normalized in memory.

| Source | Agent |
|---|---|
| `/etc/github-copilot/policy.d/*.json` (`%ProgramData%\GitHub\Copilot\policy.d\*.json` on Windows) | Copilot CLI, machine policy |
| `~/.copilot/hooks/*.json` (or `$COPILOT_HOME/hooks/`) | Copilot CLI |
| `~/.copilot/settings.json`, the `hooks` block | Copilot CLI |
| `.github/hooks/*.json` | Copilot CLI, repository |
| `.github/copilot/settings.json`, `.github/copilot/settings.local.json` | Copilot CLI, repository |
| `~/.codex/hooks.json`, `.codex/hooks.json` | Codex |
| `~/.codex/config.toml`, `.codex/config.toml`, inline `[hooks]` tables | Codex |
| `~/.codex/requirements.toml`, the enterprise-managed layer | Codex |
| `~/.claude/plugins/**/hooks/hooks.json`, for plugins `enabledPlugins` names | Claude Code plugins |
| `~/.codex/plugins/*/hooks/hooks.json`, or the path a `.codex-plugin/plugin.json` manifest names | Codex plugins |
| `~/.copilot/plugins/*/hooks.json` (or `hooks/hooks.json`) | Copilot plugins |

Copilot's camelCase event names map onto Empryo's: `preToolUse` → `PreToolUse`,
`agentStop` → `Stop`, `userPromptSubmitted` → `UserPromptSubmit`, and so on. Its
per-platform commands (`bash`, `powershell`, `command`, `exec` + `args`),
`matcher`, `timeoutSec`, `cwd` and `env` are all honored. Codex uses Claude's
schema already, so only the paths are new.

Matchers are translated too. Copilot names the shell tool `bash` where a matcher
here expects `Bash`, so its runtime names (`bash`, `powershell`, `view`,
`create`, `edit`, `str_replace_editor`, `apply_patch`, `grep`, `rg`, `glob`,
`web_fetch`, `web_search`, `ask_user`, `update_todo`, `task`) become the Claude
names before the matcher runs. A matcher already written in Claude names passes
through unchanged, and so does one containing regex characters.

On Windows, a `powershell` entry is run through `powershell -NoProfile -Command`:
the runner hands one string to `cmd.exe`, which does not speak PowerShell.

A Claude plugin's hooks run when `enabledPlugins` names it, turning the plugin
on is the decision. Codex and Copilot plugin directories carry no such switch, so
their hook files wait for review, the way Codex does it: a file is trusted by
content, and editing it asks again. `pendingPluginHooks()` reports what is
waiting, `EMPRYO_TRUST_PLUGIN_HOOKS=1` trusts every plugin file for one run.

A plugin's commands address themselves with `${CLAUDE_PLUGIN_ROOT}` (or Codex's
`${PLUGIN_ROOT}`), since a plugin cannot know its own install path, it changes
on every update. Those placeholders, plus `${CLAUDE_PLUGIN_DATA}` and
`${CLAUDE_PROJECT_DIR}`, are substituted into the command and exported to it.

Three handler forms from the other agents are honored on every source: `args`
(Claude's exec form, where each argument stays whole with no shell involved),
`shell: "powershell"`, and Codex's `command_windows` / `commandWindows` override.

Four limits worth knowing:

- A Copilot hook with no timeout gets Copilot's default of 30 seconds, not the 10 a hook in `.claude/settings.json` gets. This holds for both shapes a Copilot file can use.
- Only `type: "command"` runs. An `http`, `prompt`, or `mcp_tool` entry is skipped rather than half-run.
- `permissionRequest`, `errorOccurred`, and `userPromptTransformed` have no Empryo event and are dropped. A permission gate that silently became an observer would be worse than an absent one.
- Repository files load only after you trust the repo (`/trust`), exactly like `.claude/settings.json`. Home and policy files always load.

## Recipes

#### Block `rm -rf`

```json
{
  "matcher": "Bash",
  "hooks": [{
    "type": "command",
    "command": "echo '{\"decision\":\"block\",\"reason\":\"rm -rf blocked\"}' && exit 2",
    "if": "Bash(rm -rf *)"
  }]
}
```
Goes under `PreToolUse`. Exit code `2` denies the call.

#### Block \`.env\` everywhere at once

```json
{
  "matcher": "*",
  "hooks": [{
    "type": "command",
    "command": "jq -e '[.tool_paths[]? | select(test(\"(^|/)\\\\.env(\\\\.|$)\"))] | length == 0' > /dev/null || { echo 'no .env' >&2; exit 2; }"
  }]
}
```
Goes under `PreToolUse`. `tool_paths` is every file the call names, including
paths dug out of a shell command line, so this one rule covers `read`,
`shell`, `git`, `editor`, `code_script` and any MCP tool without naming any of
them. See [Reach](#reach).

#### Log every tool call

```json
{
  "hooks": [{
    "type": "command",
    "command": "jq -r '.tool_name' >> /tmp/sf-tools.log",
    "async": true
  }]
}
```
Goes under `PreToolUse`. No matcher = all tools.

#### Notify when done (macOS)

```json
{
  "hooks": [{
    "type": "command",
    "command": "osascript -e 'display notification \"Done\" with title \"Empryo\"'",
    "async": true,
    "once": true
  }]
}
```
Goes under `Stop`.

## Protocol

Hooks read JSON from stdin, optionally write JSON to stdout. Exit code `0` = success, `2` = block, anything else = log warning.

**stdin**
```json
{
  "session_id": "abc",
  "cwd": "/proj",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_paths": [".env", "src/app.ts"],
  "tool_input": { "command": "cat .env src/app.ts" }
}
```

**stdout** (optional - `PreToolUse` can deny, modify input, inject context)
```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Blocked by policy",
    "updatedInput": { "command": "git status --short" },
    "additionalContext": "This repo uses trunk-based dev"
  }
}
```

`PostToolUse` can inject `additionalContext` - no deny.

## Reach

A `PreToolUse` refusal stops **every** tool the model can call: the built-in
tools, the ones a session adds on top (`dispatch`, `explore_script`,
`code_script`, `python_kernel`, the desktop's `browser_*`), every MCP tool from
a server you connected, and the tools a dispatched subagent runs. Picking a
different tool is not a way around a hook.

What a hook cannot reach: anything the **provider** executes rather than Empryo
. Server-side web search and provider code execution have no local `execute` to
gate. Those are off unless you turn them on.

### Every surface fires them

The tool events (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`) follow the
tools themselves, and every surface runs the same ones. The lifecycle events
fire on the TUI, the desktop, and headless runs, which is where a pipeline's
policy and audit hooks usually live. `SessionStart`, `UserPromptSubmit`,
`Stop`, `StopFailure`, `SessionEnd`, `PreCompact`, `PostCompact` and the
`Subagent*` pair all reach a hook wherever the agent runs.

A `UserPromptSubmit` refusal in headless stops the prompt before the model is
called at all, and emits `{"type":"hook-blocked","event":"UserPromptSubmit"}` on
the `--events` stream so a pipeline can see why it stopped without parsing
stderr.

### A refusal, in every dialect it can arrive in

All four are honoured, on every event that gates:

- exit code `2`, stderr is the reason.
- `{"decision":"block","reason":"…"}` on stdout.
- `{"continue":false,"stopReason":"…"}`, Claude Code's kill switch.
- `{"hookSpecificOutput":{"permissionDecision":"deny"|"ask"}}`. **`ask` counts
  as a refusal**: Empryo has no per-hook prompt to route it to, and reading "ask
  me first" as "go ahead" is the one interpretation that must never happen.

### `failClosed`: a gate that cannot run is not a gate that said yes

A hook that times out, whose interpreter is missing, or that exits non-zero
without exiting `2` is a *non-blocking error*: the tool runs. That is Claude
Code's behaviour and the default here, because a flaky hook should not brick a
session.

For a control that must hold, say so:

```json
{ "type": "command", "command": "/opt/policy/gate", "failClosed": true }
```

Now a timeout, a spawn failure or a bad exit code refuses the action instead of
allowing it. Honoured in every dialect (Claude, Copilot, Codex). Hooks from the
**managed policy** directory are fail-closed by default, an administrator's
hook that the user cannot switch off must not switch itself off by failing to
run.

### `tool_paths`

Matching on `tool_name` alone leaves holes: a rule that denies `Read` for
`.env` watches `Bash` `cat` the same file, and matching the raw command string
catches `cat .env` but not `< .env`, `cat $(echo .env)` or `cat$IFS.env`.

So every tool event carries `tool_paths`: every filesystem path the call names,
read out of `path` / `files[].path` / an MCP server's own argument names, and, 
for a shell command, lexed the same way the [`/privacy`](/docs/reference/commands)
guard lexes it (redirects, command substitution, variable expansion). One
`*`-matched hook checking that list has the privacy guard's reach.

Paths are raw, exactly as the model wrote them, resolve them against the `cwd`
in the same payload. The field is absent when a call names no path, and it is
recomputed when an earlier hook rewrites the input.

### Verifying it on your machine

`bun run verify:hooks` runs the whole chain, a Copilot file on disk, the
per-platform command choice, the path lexer, a real hook process spawned through
this platform's shell, and a blocked tool call, and prints one line per check.
It imports no native module, so it runs anywhere Bun does: a Linux container, a
Windows box over ssh, a fresh checkout with no `node_modules`.

## Safety

- Hooks die on `Ctrl+X` along with the agent.
- Default timeout 10s. Keep hooks quick.
- Non-blocking errors (non-zero exit ≠ 2) log a warning, don't stop the agent.
