The moments in a session worth intercepting
Every harness lets you run a command at fixed points in a session. Five of those moments are worth the wiring, and knowing which one you need decides whether you built a gate or an alarm.
The instruction you cannot enforce
The "instructions" can't be enforced. Let's say your project has a memory file saying not to push to main directly, and a prompt telling the agent to run the formatter before it finishes. Both these rules are sensible and work in most cases, but there's one thing — "most" is a problem here, because instruction is a request addressed to a probabilistic system; you can't be certain it will be followed until you inspect the diff after the session.
The hook is different, it's a command that is being executed by the harness at a defined point of every session, it also aims to achieve the same goal — not to push to main directly — but in a different way: request vs determination.
This is sth that you can't really communicate using a script — scripts can't reason or assess a design; they can ask a question and answer it with certainty at one specific point, always the same way. The most valuable skill in terms of hooks isn't writing them, but choosing which moments require their intervention. Most harnesses expose many more events than is sensible to actually use.
Five moments, whatever your tool calls them
There are a few events that occur during sessions across almost all tools:
Prompt submitted (before the model sees it)
Before tool runs
After tool runs
Agent says "stop"
Session start and end
And there's two more if the session is long:
Compaction boundary — before the harness is about to compact context (that might be important) away
Subagent boundary — before a delegated session starts and after it returns; most of these tools have it, not all
This is not a coincidence. For example, have a look at this converter: gemini hooks migrate --from-claude. It's a part of the migration from Claude Code to Gemini, it converts a settings file for the previous tool into an equivalent one for the new one. Among other things, it translates PreToolUse -> BeforeTool and PostToolUse -> AfterTool and Stop -> AfterAgent; SessionStart is not changed. It also renames tools in matchers, like Bash -> run_shell_command. You can't create such converter if these systems don't have the same events.
In the rest of this lesson we go through what you can do at each of these moments, and what each one costs.
Before the prompt reaches the model
This event happens after you submit your prompt, before the model sees anything; there are two sensible use cases:
Providing some truths that change every turn — for example, which branch is currently being worked on, or what's the number of a current ticket, or what's the migration level of the local database, or what's today's date; these are things that change so they can't be kept in the memory file (that's static), but a hook can always create them on the fly.
Rejecting the turn — there are not many cases for it, mostly regulatory. For example, if you want to make sure that whatever you write in the prompt doesn't end up with some model provider.
There are two costs associated with this event:
It runs on every prompt, so everything you return is being tokenised and billed for every turn.
This is a great place to create an oversized memory file. Which is to say: if you had one and replaced it with the hook, now you can just recreate it here; the proper way would be to write ten lines that are different in every turn, not a hundred that are always the same.
Before a tool runs
This is a gate — the only moment when a call can still be declined, so the place for never git push --force, never read .env, never hand-edit a generated migration.
There are three factors to consider if you want your gate to be useful rather than irritating:
Keep matchers tight — the hook that's bound to all tool calls lies on the path of all tool calls. If it's bound to shell commands that look like
git push, it's not a problem, but Claude Code emits a warning for slow hooks on this event, which tells you how often people mess it up.Address the refusal text to the model, not to the log — the reason printed by the hook is the next input for the model, so address it as an instruction rather than a status update. For example: "Please use rg instead of grep"
Don't list bad things — people sometimes create hooks like this, that deny everything if
rm -rfis used. It's a losing battle though, becausefind . -deletecan still be used; it's better to gate a specific destructive action in your repository and rely on sandboxing and permissions which are in place for this purpose.
After a tool ran
This is a reaction — the formatter can run here (after you wrote the file), or the type-checker can run on it, or the audit trail can be extended with the command, or you can return a failing test as context, but there's no undo. The write is already done, so this event is different than the previous one: before-event is a gate, after-event is an alarm; that's the most common confusion in this chapter. "Must never happen" is the previous event, "do sth when it happens" is this one.
There's another sub-event here as well — if the tool throws. Most of the harnesses have a separate event for this scenario, which is a perfect place to put things you'd otherwise put to the memory file and leave it to chance, like saying that a certain command needs a flag or that the build must be run from a subdirectory; in such scenario you give the model these instructions right after it encountered the problem.
When the agent says it is finished
This is the most powerful and least often used hook; the agent told you that it thinks the turn is done, you can tell it that you don't think so.
Run the tests, return their failures so the turn continues with a sure knowledge that you were doing sth that doesn't build instead of an automated summary at the end of the session.
There are two things to keep in mind:
It's slower if you have a slow test suite. In practice, people only run the quick subset of tests here.
It can lead into an infinite loop — if you reject it, you create work which is creating another stop event that will trigger the hook again; Claude Code passes a flag to the hook telling it a stop hook is already continuing the turn, and both it and Copilot terminate the turn anyway after 8 consecutive blocks; Cursor has a default limit of 5 automatic follow-ups (which can be changed).
The session boundary — start and end
This is cheap and underused, and there are a few good cases for it:
Session start: marking the session with currently-true state instead of keeping it somewhere where it will expire, like what's the branch or if the dev server is running or what's the most recent production deploy. There's a little difference here too, as the hook receives a flag saying if the session is fresh so it can return only deltas if it's not.
Session end: closing the container you've started, removing the scratch directory, forwarding the audit trail.
These are all things that don't affect the model behaviour — this is the point of these hooks, determinism at its cheapest.
Hooks fail open by default
Almost everywhere a hook that exits with a non-zero exit code (except the blocking one) or times out is a permit hook. As Kimi Code docs say "suitable for alerts and lightweight interception, but should not be used as the sole security barrier". In Copilot CLI it works differently, non-zero exit from preToolUse is a denial on this event (but a timeout is still a permit), Cursor has a per-hook failClosed flag set to false by default.
You need to exercise the failure path — if you haven't seen your hook failing, you don't know how it works.
Hooks are not trust boundaries — there's more things protecting the repository than hooks, like sandboxing and permissions.
They can also be silently ignored in case they're not loaded. A few tools make project-level hooks subject to a trust context (Codex documents this, Gemini CLI doesn't work in an untrusted directory at all), but during our tests with Claude Code it was loading project hooks in a new temp directory. It's not predictable so always make sure you run the hook and see that it works before moving on; a hook that you haven't seen running is just a guess.
The contract — the three exit codes
Command hooks are being called with the harness writing a JSON to stdin, and the script deciding and returning the exit code.
0— proceed, the session continues. The harness will also read JSON fromstdoutif you want to return more info — some additional context, modified tool input, a decision object.2— block (in Copilot CLI it's only a warning, except on its permission events) — the reason is being printed tostderrand usually goes to the model.Anything else — the hook is broken and the session continues; the harnesses have different behaviours per event in this scenario, for example:
Copilot CLI blocks on any non-zero exit from
preToolUse.Claude Code cancels worktree creation if there's a non-zero exit from its worktree event.
But — don't use exit 1 to block anything. It's the standard exit code for shell scripts to indicate an error, and that's why people often write hooks and then say that they don't work (because they don't).
You can have other types of handlers as well, depending on the harness — an HTTP endpoint or an LLM prompt or a subagent with access to tools; these are good if you want to implement some org-wide policy there but keep in mind that it's adding an http call or an LLM call to the hooked path, so always start with a script.
Choosing the right moment
If you ever wonder which of these events is best for your purpose, ask yourself these questions:
Is determinism needed? — if "usually" works for you, put it to the memory file; hooking everything on every tool call is an endless cost.
Stop or observe? — stopping (before-event) is a gate in the critical path, observing (after-event) is cheaper to implement and cheaper to get wrong.
How tight is the matcher? — it's about the difference between a hook that runs on force pushes and one that runs on every shell command of the session.
Is there already sth? — the best hook for most repositories is a single line call to the team's pre-commit hook, linter, or test target; don't reinvent the wheel in the harness.
What to leave alone
Don't try to do the model's work. The hooks are not about reviewing designs or recognising that this refactor looks good but isn't.
Don't attach all the hooks at once on day one, it'll make you unable to say what hook slows the session down or which one fails open silently.
Avoid enumerations, we already mentioned the deny-lists thing.
Don't use these hooks if you actually need the security they provide — they fail open.
In Claude Code
We have a hooks configuration setting in Claude so you can configure PreToolUse hook via the hooks section of your settings (in this case it will be ~/.claude/settings.json, .claude/settings.json or .claude/settings.local.json), like:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh" }
]
}
]
}
}This way the PreToolUse hook is being set up for Bash, so it will run $CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh every time the model reaches for that tool. Claude's life cycle has five stages that can be listened to via events:
UserPromptSubmitPreToolUsePostToolUse(includingPostToolUseFailure)StopSessionStart/SessionEnd
The reference lists more than 30 of them, but the majority is not needed at all. The most sensible choices would probably be PermissionRequest, PreCompact and PostCompact, and SubagentStop.
A PreToolUse hook that exits 2 does what you'd hope. Here's the tool result we got from a guard script that wrote its reason to stderr and refused echo hello:
PreToolUse:Bash hook error: [$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh]: blocked by the lab
guard: echo is not allowed in this testClaude respects the exit status 2 from the script and doesn't run echo hello because of it, and the reason lands in the tool output as you can see above. That was clear enough for the model to treat the block as intentional and not reach for printf instead. The full JSON format is a bit more verbose, it uses hookSpecificOutput with permissionDecision set to allow, deny or ask, but also a defer value which exits gracefully so the tool can be resumed later, if you want your hooks to let some tool calls through.
The gotcha: --bare and --safe-mode both start a session with hooks disabled (along with other configuration options), which might be useful to understand what the behaviour on your machine is, if you run /hooks in such a session you can see which hooks are being loaded.
In Codex CLI
The hooks feature is marked stable in Codex CLI (codex features list shows hooks stable true), and here's how it works:
Basically, the hooks are defined in the hooks.json file, or as inline [hooks] tables inside config.toml (similar to how layers are defined there). You can place them under ~/.codex/ or inside <repo>/.codex/. There are 4 possible locations:
User-level
hooks.jsonunder~/.codex/User-level
config.tomlunder~/.codex/Repo-level
hooks.jsonunder<repo>/.codex/Repo-level
config.tomlunder<repo>/.codex/
What's important is that it's a layering system, so all the hooks in matching places across all layers are executed (so hooks from the higher layers are actually being added to those from the lower ones).
For people coming from Claude Code, this structure should be familiar enough that it's mostly copy-paste, and the event names are even the same words. In terms of events, there are a few shared with Claude Code and some new ones:
UserPromptSubmitPreToolUsePostToolUseStopSessionStartSessionEndPermissionRequestPreCompactPostCompactSubagentStartSubagentStop
For instance, here's how you can define the PreToolUse hook for Bash which runs a local guard script as a command hook with a 600-second timeout (timeout is in seconds here, and 600 is also the default for most events):
hooks.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".codex/hooks/guard.py", "timeout": 600 }
]
}
]
}
}The event is being matched using regular expression against the tool's name, but since every shell invocation is Bash (including the consolidated exec_command route) it doesn't work if you key it on shell. If you want your hooks triggered for file modifications, match on apply_patch, Edit or Write.
Codex's own docs group these events by their timing (what happens in the middle of a turn, when a session or subagent starts, and when the main thread ends) which is also consistent with the structure above.
But there's one thing to keep in mind — hooks require you to trust them before they can be executed. The trust is bound to hashes of their definition so any modification to the existing hook or introduction of a new one will make it skipped until you approve it. You'll see a message during the start pointing you to /hooks where you can inspect sources, review changes and disable individual hooks if needed. If you have project-local hooks defined too, then you need to trust your project as well to make them work — otherwise only user- and system-level hooks are loaded. There's also an option of --dangerously-bypass-hook-trust flag which is meant for use cases in which you have your automation validating hook sources but just be mindful of it. And worth spelling out: modify a hook without re-approving it and you've quietly deactivated one of the gates you thought you were behind.
In GitHub Copilot CLI
Hook events are camelCased and can be PascalCased too (they're aliases to Claude's PascalCase forms), eg:
sessionStartuserPromptSubmitteduserPromptTransformedpreToolUsepostToolUsepostToolUseFailurepreCompactpermissionRequestagentStopsubagentStartsubagentStopnotificationerrorOccurredsessionEnd
For example, these 5 are the most significant:
userPromptTransformedpreToolUsepostToolUseagentStopsessionStart/sessionEnd
If you want to transform the prompt, make sure to use userPromptTransformed. This one is a bit special, because there's another event that looks right for it — userPromptSubmitted — but it fires on submit and its output is ignored; only userPromptTransformed can alter the current turn.
Hooks are defined in these places (in this order):
machine policy
~/.copilot/hooks/or ahookskey inside~/.copilot/settings.json.github/hooks/*.jsonor ahookskey inside.github/copilot/settings.jsoninstalled plugins
This is what a JSON config file looks like when you register a command for preToolUse with a matcher:
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"matcher": "bash",
"bash": "./.github/hooks/guard.sh"
}
]
}
}But be careful, the pattern is wrapped as ^(?:PATTERN)$ and must match the entire tool name. For example, the shell tool is called bash (or powershell on Windows), so shell doesn't match anything at all.
Copilot has 3 of its own features in this regard:
different types of handlers:
command— a regular handler that runs a scripthttp— a handler that sends a POST request to some URLprompt— a special handler that automatically "types" some text (in a way, like the user does), but it's only available atsessionStart
machine policy hooks are not affected by
disableAllHooksand require root permissions to be modifiedpreToolUseis the only event that by default works in a fail-closed mode — if the hook fails, the action is blocked; for all other events, it's fail-open (anything goes), the same applies to timeouts as well, which are also fail-open everywhere, includingpreToolUse
Make sure you verify the hook is really loaded before using it. We tested this in a disposable directory on 1.0.77: with sessionStart defined in .github/hooks/, nothing was logged (in both a plain folder and a freshly initialised git repository). Folder trust is the likely reason, since the reference says machine policy hooks work "regardless of folder trust state", which implies the rest don't. If you want to check what hooks a session loaded, you can use /env in the CLI (make sure you run it in the directory you actually work in).
In Cursor
Cursor's implementation is based around hooks, they are defined in a single file either in ~/.cursor/hooks.json for your local setup or in <project>/.cursor/hooks.json if it's part of a project. There are two higher layers above those as well, team and enterprise. The hooks system is event-based but with a twist — the events are grouped per tool, so for example there are both generic preToolUse, postToolUse, postToolUseFailure, subagentStart, subagentStop and tool-specific beforeShellExecution, beforeReadFile, beforeMCPExecution, afterFileEdit, afterShellExecution, afterMCPExecution. There are also few more general ones like beforeSubmitPrompt, stop, sessionStart, sessionEnd, preCompact, afterAgentResponse, afterAgentThought, workspaceOpen.
The split into separate events is the point — you can gate shell commands while leaving every other tool alone, and you get the command string itself in the beforeShellExecution hook. For example you can define a timeout for shell commands and set failClosed to true:
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": "./hooks/guard.sh", "timeout": 30, "failClosed": true }
]
}
}That way the call stays gated even if the hook itself breaks. The hooks communicate with the engine through stdout using JSON, so they can return deny response and two messages — one which will be shown to the user and another one which will be passed to the LLM. In a session we ran, this is what it looked like: {"permission": "deny", "user_message": "…", "agent_message": "…"}. The shell call was refused, and the agent reported the block instead of quietly trying another command.
There are also other ways to communicate with the engine from hooks. For example the beforeSubmitPrompt hook can return {"continue": false}, which says we don't want this prompt submitted, or the stop hook can return a followup_message that gets submitted automatically (up to loop_limit times, 5 by default).
When it comes to using hooks there are few things you need to remember:
relative paths in scripts are resolved based on the file where the hook is defined — for project-level hooks relative to the project's root and for user-level hooks relative to
~/.cursor/failClosedis set to false by default so if you're writing a security hook you need to remember about setting it to truefor cloud agents there's a large part of hooks that are not being called at all like
sessionStart,sessionEnd, MCP events, Tab events,workspaceOpenand prompt-related hooks
In Gemini CLI
Hooks in Gemini CLI, they're defined in the hooks section of the settings.json file, and are composed of 4 sources (in order of precedence):
The project-level
.gemini/settings.jsonThe user-level
~/.gemini/settings.jsonThe system-level
/etc/gemini-cli/settings.jsonHooks defined by installed extensions
They can be configured for two kinds of events that occur during the lifecycle of a session:
Events that carry Google's own names (
BeforeTool,AfterTool,BeforeAgent,AfterAgent,BeforeModel,AfterModel,BeforeToolSelection,PreCompress)Events spelled the way everyone else spells them (
SessionStart,SessionEnd,Notification)
There are 5 common lifecycle points which correspond to the following hooks:
BeforeAgentBeforeToolAfterToolAfterAgentSessionStart/SessionEnd
For example, here's how you can configure a BeforeTool hook for the shell tool, pointing at a guard.sh under the project directory with a 5-second timeout — Gemini counts this field in milliseconds, unlike the others:
{
"hooks": {
"BeforeTool": [
{
"matcher": "run_shell_command",
"hooks": [
{ "name": "guard", "type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/guard.sh", "timeout": 5000 }
]
}
]
}
}In terms of tool events, it's worth noting that Gemini CLI uses its own identifiers for tools (like run_shell_command or replace/write_file), not the ones the other harnesses use. If you want to block a tool from running, you can set the exit code to 2 and provide an explanation on stderr, or return JSON with {"decision": "deny"} on stdout.
What's really important is that BeforeModel / AfterModel are hooks for the model request itself, not the user prompt or a tool call — no other harness here has an event on that.
Also gemini hooks migrate --from-claude converts an existing Claude Code hooks block into Gemini's vocabulary, rewriting the event names and the tool names in the matchers, and the hook environment even carries CLAUDE_PROJECT_DIR as an alias for GEMINI_PROJECT_DIR.
But what's really tricky is to understand which Google CLI you're using. The consumer tiers have been migrated to Antigravity CLI in June 2026, and if you're using Gemini CLI v0.46.0 on an individual account it will fail before the session starts, with a tier error pointing you to Antigravity.
As mentioned above, they are separate projects — Antigravity is not just a renaming of Gemini. It has its own hooks in a separate file at the customization root — .agents/hooks.json. Here, you define the hooks by their names, not by events. There are 5 events supported:
PreToolUsePostToolUsePreInvocationPostInvocationStop
In the PreToolUse event, there's a wider range of options compared to Gemini: you can allow the tool, deny it, ask about it or force the user to answer (force_ask), and also provide an overwrite object which modifies the arguments before running the tool.
So remember — if you're trying to configure hooks for a Google CLI, make sure you're using the right one.
In Kimi Code CLI
Hooks in Kimi Code live in one config file, and a rule looks like this:
[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "./.kimi/hooks/guard.sh"
timeout = 10It's a flat TOML array in the ~/.kimi-code/config.toml file. For every rule, it contains a single table. The event is defined as one of the fields inside this table, not as a parent key, so the configuration file doesn't contain any nesting at all.
There are four fields that you can define:
eventmatchercommandtimeout(in seconds from 1 to 600, defaults to 30)
The configuration file is very opinionated and rejects everything that doesn't adhere to this schema, so if you add any extra key it won't be loaded at all. Make sure to run kimi doctor after every modification in the file to check if both configuration files are valid. In case of an invalid event name it will list all valid ones.
In version 0.31.1 of the Kimi Code there are 16 events total:
PreToolUsePostToolUsePostToolUseFailurePermissionRequestPermissionResultUserPromptSubmitStopStopFailureInterruptSessionStartSessionEndSubagentStartSubagentStopPreCompactPostCompactNotification
For example, if you add a description key to one of the rules, you'll see an error saying hooks[0]: Unrecognized key: "description". As the configuration file isn't loaded in this scenario, it's important to run kimi doctor after every modification to see such messages.
Out of these 16 events, only three can be used for blocking:
PreToolUseUserPromptSubmitStop
The rest are purely observational and running a command in them doesn't have any effect. The Kimi Code docs are unusually direct about it — hooks are "suitable for alerts and lightweight interception, but should not be used as the sole security barrier" — and they point at permission approvals for anything genuinely high-risk.
These are the ways you can block an operation:
Using exit code 2 and writing an explanation on
stderrSetting
hookSpecificOutput.permissionDecisionto"deny"in JSON onstdout
Here's an example reason from the Kimi Code docs, and it's the right tone for these messages: "Please use rg instead of grep".
If you define multiple rules for the same event, they will be run in parallel. If you define the same command in multiple rules, it will be run only once.
Start with one gate
The best way to get familiar with your harness's hooks system is to implement a single gate — choose the thing you wouldn't want to do in this repository (like force push or alter a migration file or write something to the production config) and attach a before-tool hook to it, phrase the refusal as an actionable instruction, and then try to do the thing and see that it's being blocked. The remaining part of this chapter covers blocking the commit that should be rejected, moving format/lint/test from the prompt to hooks, making a refusal loud enough that the model doesn't circumvent it, and assembling the guardrail set a team of six can share.