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

# Hooks

> Run your own shell commands when the agent uses a tool, starts or ends a session, or compacts. Works with Claude Code hooks and reads Copilot and Codex hooks.

Hooks run your own shell commands at set moments: before or after the agent uses a tool, when you send a prompt, when a turn ends, when a session starts or ends, and around context compaction. Use them to format files after edits, block commands you never want run, log activity, or send a notification when the agent finishes.

Empryo uses the same format as Claude Code. Hooks you already have in `.claude/settings.json` work without changes.

## Quick example

Format every file the agent edits with Prettier:

```json .empryo/config.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit|ast_edit",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.path // empty' | xargs -r prettier --write",
            "async": true
          }
        ]
      }
    ]
  }
}
```

The hook receives the tool call as JSON on stdin, so `jq` pulls out the edited file's path.

Run `/hooks` inside Empryo to see which hooks are active.

## Events

| Event | When it runs | What `matcher` filters on |
|-------|-------|---------|
| `PreToolUse` | Before a tool runs | Tool name |
| `PostToolUse` | After a tool succeeds | Tool name |
| `PostToolUseFailure` | After a tool fails | Tool name |
| `UserPromptSubmit` | When you send a message | none |
| `Stop` / `StopFailure` | When a turn ends, or ends with an error | none |
| `SessionStart` / `SessionEnd` | When a session starts or ends | none |
| `PreCompact` / `PostCompact` | Before and after context compaction | none |
| `SubagentStart` / `SubagentStop` | When a helper agent starts or finishes | none |
| `Notification` | When Empryo waits for you: an approval, a question, or a plan to review | none |

`Notification` never waits for the hook and can't block. Its payload has `message` and `notification_type`: `permission_prompt` for an approval, `elicitation_dialog` for a question, `plan_review` for a plan.

### Where each event fires

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

`SessionEnd` runs while Empryo is shutting down, so each `SessionEnd` hook gets at most 3 seconds.

## Rule format

```json
{
  "matcher": "Bash|Edit",       // tool names separated by |, or a regex
  "hooks": [
    {
      "type": "command",
      "command": "my-hook.sh",
      "async": false,             // true: run in the background, don't wait
      "timeout": 10,              // seconds (default 10)
      "once": false,              // true: run only once per session
      "if": "Bash(git *)",        // extra filter on the tool's first argument
      "failClosed": false         // true: block the tool if the hook itself fails
    }
  ]
}
```

A missing or `"*"` matcher matches every tool.

`if` narrows a rule by the tool's first string argument: `Bash(rm *)`, `Edit(*.ts)`. `*` matches any run of characters and `?` matches one.

Tool names follow Claude Code (`Bash`, `Edit`, `Write`, `Read`, `Grep`, `Glob`, `WebSearch`, `Agent`), and Empryo maps its own tools onto them. You can also match an Empryo tool by its own name, such as `ast_edit`.

## Where hooks are read from

Empryo merges hooks from these files, in this order:

1. Claude Code managed policy: `/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`

Hook files inside a repository only load after you trust that repository with `/trust`. Files in your home folder and policy files always load.

Set `"disableAllHooks": true` in any of these files to turn hooks off. Managed policy hooks still run, so an organization's audit hook can't be switched off by a user.

### Hooks written for Copilot CLI or Codex

Empryo also reads the hook files that Copilot CLI and Codex use, so a hook you set up once works in all of them. The files are read where they are; nothing is copied or changed.

| Source | Tool |
|---|---|
| `/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 listed in `enabledPlugins` | Claude Code plugins |
| `~/.codex/plugins/*/hooks/hooks.json`, or the path a `.codex-plugin/plugin.json` names | Codex plugins |
| `~/.copilot/plugins/*/hooks.json` (or `hooks/hooks.json`) | Copilot plugins |

How the differences are handled:

- **Event names.** Copilot's names are mapped: `preToolUse` → `PreToolUse`, `agentStop` → `Stop`, `userPromptSubmitted` → `UserPromptSubmit`, and so on. Codex already uses Claude's names.
- **Tool names in matchers.** Copilot's tool names (`bash`, `powershell`, `view`, `create`, `edit`, `str_replace_editor`, `apply_patch`, `grep`, `rg`, `glob`, `web_fetch`, `web_search`, `ask_user`, `update_todo`, `task`) are translated to Claude's before matching. Matchers already in Claude names, or containing regex characters, are used as written.
- **Command forms.** Copilot's per-platform commands (`bash`, `powershell`, `command`, `exec` + `args`), `matcher`, `timeoutSec`, `cwd` and `env` are all supported. So are Claude's `args` form, `shell: "powershell"`, and Codex's `command_windows` / `commandWindows`. On Windows, a `powershell` entry runs through `powershell -NoProfile -Command`.
- **Plugin paths.** `${CLAUDE_PLUGIN_ROOT}` (or Codex's `${PLUGIN_ROOT}`), `${CLAUDE_PLUGIN_DATA}` and `${CLAUDE_PROJECT_DIR}` are filled in and also set as environment variables.

A Claude Code plugin's hooks run when the plugin is listed in `enabledPlugins`. Codex and Copilot plugin folders have no such switch, so their hook files don't run until they are trusted, and editing a file makes it untrusted again. To trust all plugin hook files for a single run, set `EMPRYO_TRUST_PLUGIN_HOOKS=1`.

Limits to know:

- A Copilot hook without a timeout gets Copilot's default of 30 seconds, not 10.
- Only `type: "command"` hooks run. `http`, `prompt` and `mcp_tool` entries are skipped.
- Copilot's `permissionRequest`, `errorOccurred` and `userPromptTransformed` events have no Empryo equivalent and are ignored.

## Recipes

**Block `rm -rf`**

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

**Block `.env` files for every tool**

```json
{
  "matcher": "*",
  "hooks": [{
    "type": "command",
    "command": "jq -e '[.tool_paths[]? | select(test(\"(^|/)\\\\.env(\\\\.|$)\"))] | length == 0' > /dev/null || { echo 'no .env' >&2; exit 2; }"
  }]
}
```
Put this under `PreToolUse`. `tool_paths` lists every file the call touches, including paths inside a shell command, so this one rule covers reads, shell commands, git, the editor, scripts and MCP tools. See [`tool_paths`](#tool_paths).

**Log every tool call**

```json
{
  "hooks": [{
    "type": "command",
    "command": "jq -r '.tool_name' >> /tmp/empryo-tools.log",
    "async": true
  }]
}
```
Put this under `PreToolUse`. With no matcher, it runs for every tool.

**Notify when done (macOS)**

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

## How a hook talks to Empryo

A hook reads JSON on stdin and can optionally write JSON to stdout. Exit code `0` means success, `2` means block, and anything else logs a warning.

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

`transcript_path` is the session's event log, present whenever the session has an id.

Hooks get a safe subset of Empryo's environment (`PATH`, `HOME`, locale, toolchain and terminal variables), never credentials. To pass more, start Empryo with `EMPRYO_HOOK_ENV` set to a list of names, where a trailing `*` matches a prefix: `EMPRYO_HOOK_ENV="GHOSTEX_* MY_HOST_ID"`. A terminal host sets it when it launches Empryo, so its own variables reach every hook. A hook's `env` field adds fixed values.

Non-tool events put their details at the top level: `prompt` for `UserPromptSubmit`, `source` for `SessionStart`, `reason` for `SessionEnd`, `trigger` for the compaction events, `error` for `StopFailure`.

**stdout** (optional; `PreToolUse` can block the call, change its input, or add context for the model)
```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Blocked by policy",
    "updatedInput": { "command": "git status --short" },
    "additionalContext": "This repo uses trunk-based dev"
  }
}
```

`PostToolUse` can add `additionalContext` but can't block.

### Ways to block

Any of these blocks the action:

- Exit code `2`. Whatever the hook wrote to stderr becomes the reason.
- `{"decision":"block","reason":"…"}` on stdout.
- `{"continue":false,"stopReason":"…"}`, Claude Code's stop switch.
- `{"hookSpecificOutput":{"permissionDecision":"deny"}}` or `"ask"`. **`ask` is treated as a block**, because Empryo has no way to pause and ask you from inside a hook, and treating "ask me first" as "go ahead" would be unsafe.

In a headless run, a `UserPromptSubmit` block stops the prompt before the model is called. With `--events`, Empryo emits `{"type":"hook-blocked","event":"UserPromptSubmit"}` so your pipeline can see why it stopped.

### What blocking covers

A `PreToolUse` block applies to every tool the agent can call: built-in tools, script tools, the browser, tools from any MCP server you connected, and tools used by helper agents. Switching to a different tool doesn't get around a hook.

A hook can't block anything the model **provider** runs on its own servers, such as provider-side web search or code execution. Those have no local step to intercept.

### `failClosed`: when the hook itself fails

By default, if a hook times out, can't start, or exits with a code other than `0` or `2`, the tool runs anyway. That matches Claude Code, and it means a flaky hook can't lock you out of a session.

For a check that must hold, add `failClosed`:

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

Now a timeout, a failed start or an unexpected exit code blocks the action. This works for Claude, Copilot and Codex hook files. Hooks from a managed policy file fail closed by default.

### `tool_paths`

Matching on `tool_name` alone leaves gaps. A rule that blocks `Read` on `.env` doesn't stop `Bash` from running `cat .env`. Matching the command text catches `cat .env` but not `< .env`, `cat $(echo .env)` or `cat$IFS.env`.

So every tool event includes `tool_paths`: every file path the call refers to. Empryo collects them from the tool's arguments, including MCP tools, and parses shell commands the same way the [`/privacy`](/docs/reference/commands) file guard does, including redirects, command substitution and variables. One hook that checks this list covers every tool.

Paths are exactly as the model wrote them. Resolve relative paths against `cwd` from the same payload. The field is left out when a call names no paths, and it's recalculated if an earlier hook changed the input.

## Good to know

- Stopping the turn (`Ctrl+X` in the terminal UI) also stops running hooks.
- The default timeout is 10 seconds. Keep hooks quick.
- A failing hook (any exit code other than `0` or `2`) logs a warning and doesn't stop the agent, unless it has `failClosed`.
