Hooks
Run shell commands on agent lifecycle events: before/after tool calls, session start/stop, compaction. Wire-compatible with Claude Code settings.json hooks.
Copy & share
Loading sections…
Connect MCP or install the Empryo skillSection exports contain only that heading’s content. Markdown and text links fetch the selected content directly, without the rest of the page.
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:
{
"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
{
"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 run inside the tool belt, so they fire on every surface. SessionEnd runs synchronously, capped at 3 seconds per hook, because the process is exiting while it runs.
Config sources (merged in order)
- 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) ~/.claude/settings.json.claude/settings.json.claude/settings.local.json~/.empryo/config.jsonand~/.empryo/hooks.json.empryo/config.jsonand.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.jsongets. This holds for both shapes a Copilot file can use. - Only
type: "command"runs. Anhttp,prompt, ormcp_toolentry is skipped rather than half-run. permissionRequest,errorOccurred, anduserPromptTransformedhave 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
Protocol
Hooks read JSON from stdin, optionally write JSON to stdout. Exit code 0 = success, 2 = block, anything else = log warning.
stdin
{
"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)
{
"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 belt, the tools 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 belts dispatched subagents run on. Choosing 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) ride the tool belt, which every surface shares. The lifecycle events fire on the TUI, the desktop and headless — including --headless, which is where a pipeline's policy and audit hooks actually run. 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"}}. **askcounts
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:
{ "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` 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+Xalong with the agent. - Default timeout 10s - hooks should be fast.
- Non-blocking errors (non-zero exit ≠ 2) log a warning, don't stop the agent.