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.
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:
{
"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
| 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 firesLink to this section
| 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 formatLink to this section
{
"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:
- 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) ~/.claude/settings.json.claude/settings.json.claude/settings.local.json~/.empryo/config.jsonand~/.empryo/hooks.json.empryo/config.jsonand.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.
| 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,cwdandenvare all supported. So are Claude'sargsform,shell: "powershell", and Codex'scommand_windows/commandWindows. On Windows, apowershellentry runs throughpowershell -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,promptandmcp_toolentries are skipped. - Copilot's
permissionRequest,errorOccurredanduserPromptTransformedevents have no Empryo equivalent and are ignored.
RecipesLink to this section
Block `rm -rf`
{
"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
{
"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
{
"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)
{
"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
{
"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)
{
"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:
{ "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+Xin the terminal UI) also stops running hooks. - The default timeout is 10 seconds. Keep hooks quick.
- A failing hook (any exit code other than
0or2) logs a warning and doesn't stop the agent, unless it hasfailClosed.