Skip to content

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.

Tools8 minutes to read
Copy & share
On this page13 sections

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 exampleLink to this section

Format every file the agent edits with Prettier:

.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.

EventsLink to this section

EventWhen it runsWhat matcher filters on
PreToolUseBefore a tool runsTool name
PostToolUseAfter a tool succeedsTool name
PostToolUseFailureAfter a tool failsTool name
UserPromptSubmitWhen you send a messagenone
Stop / StopFailureWhen a turn ends, or ends with an errornone
SessionStart / SessionEndWhen a session starts or endsnone
PreCompact / PostCompactBefore and after context compactionnone
SubagentStart / SubagentStopWhen a helper agent starts or finishesnone
NotificationWhen Empryo waits for you: an approval, a question, or a plan to reviewnone

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 firesLink to this section

EventTerminal UIDesktopHeadless
PreToolUse, PostToolUse, PostToolUseFailureyesyesyes
UserPromptSubmit, Stop, StopFailureyesyesyes
SessionStart, SessionEndyesyesyes
PreCompact, PostCompactyesyesno
SubagentStart, SubagentStopyesyesno
Notificationyesyesno

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

Rule formatLink to this section

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 fromLink to this section

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 CodexLink to this section

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.

SourceTool
/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 blockCopilot CLI
.github/hooks/*.jsonCopilot CLI, repository
.github/copilot/settings.json, .github/copilot/settings.local.jsonCopilot CLI, repository
~/.codex/hooks.json, .codex/hooks.jsonCodex
~/.codex/config.toml, .codex/config.toml, inline [hooks] tablesCodex
~/.codex/requirements.toml, the enterprise-managed layerCodex
~/.claude/plugins/**/hooks/hooks.json, for plugins listed in enabledPluginsClaude Code plugins
~/.codex/plugins/*/hooks/hooks.json, or the path a .codex-plugin/plugin.json namesCodex 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.

RecipesLink to this section

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`.

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 EmpryoLink to this section

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 blockLink to this section

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 coversLink to this section

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 failsLink to this section

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_pathsLink to this section

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` 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 knowLink to this section

  • 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.