Why your Gemini CLI hook doesn't fire
A hook in Gemini CLI is a shell command the CLI runs at a named point in its loop: before a tool, after a turn, when a session starts. You declare it under hooks in a settings file. At the event, Gemini CLI starts the command, writes a JSON object to its stdin, and reads one JSON object from its stdout. "Not firing" can mean any of those stages failed: the hook was never loaded, its matcher never matched, or it ran and the CLI could not use the answer.
Run gemini --version first. This page was checked against v0.65.0, the stable release on 20 October 2026, and the source at commit 363a131.
Checked against Gemini CLI's documentation and source on 27 October 2026.
Status, 27 October 2026: AfterAgent is documented as once per turn, but an open report says it never ran on v0.45.0, and a comment on it extends the claim to AfterTool on v0.55.1 (#27712, open, last activity 20 October 2026). A SessionStart report used a matcher that cannot match under the exact-string rule (#28160, closed as not planned on 10 September 2026). The Claude Code migration matcher mismatch is still open (#14724, updated 27 October 2026). Unpaid-tier and Google One users were moved to Antigravity CLI on 18 June 2026, so this page applies to paid, API-key, Vertex AI and enterprise installs; the Gemini CLI hub has the details.
Loaded, matched, ran: three different failures#
The distinction matters because the fix is different at each stage.
| What you see | What failed | First check |
|---|---|---|
/hooks panel does not list the hook | It never loaded | Event name, settings file, hooksConfig.enabled |
| It is listed as disabled | It loaded but is off | /hooks enable <name>, hooksConfig.disabled |
| It is listed and enabled, but never runs | Trust or matcher | Folder trust, matcher string |
| Your log shows it ran, but the decision did nothing | Output or exit code | stdout JSON, exit status |
| The event you expect never happens | Event semantics | The event table below |
Work down the table in order. Editing the matcher will not help a hook that was never loaded.
It never loaded#
Hooks live in settings.json, in a hooks object. Gemini CLI combines the hook arrays from the project file .gemini/settings.json, the user file ~/.gemini/settings.json, the system file /etc/gemini-cli/settings.json, and installed extensions. They are concatenated, not replaced, so a project hook and a user hook for the same event both run. The hooks overview describes the layers and the schema.
Four things keep a hook from running from the settings file:
- The hooks system is off.
hooksConfig.enabledmust betrue; it is by default, but changing it needs a restart. When it is off,/hooks panelcan say "No hooks configured" rather than "hooks are disabled", so the panel is not proof that the file is empty. - The settings file was edited during a session. Hook definitions are read when the CLI starts and the registry is initialized once. Save the file and start a new session.
/hooks enableand/hooks disablechange the running registry, but adding a new hook definition does not. - The event name is wrong.
BeforeToolandAfterToolare not Claude'sPreToolUseandPostToolUse; a Claude event name is reported as an invalid hook event and skipped. The names are exact and case-sensitive. - The hook is in the disabled list.
hooksConfig.disabledholds names (or commands) that should not run, and the list is merged across layers./hooks disable <name>saves the name to the disabled list, normally in the workspace settings file; check both.gemini/settings.jsonand~/.gemini/settings.jsonif a hook keeps coming back disabled.
A hook definition also needs type: "command" and a command. timeout is in milliseconds, not seconds, and the default is 60000. A definition with a missing command is discarded at load time.
/hooks panel is the load check. It lists the hooks the registry actually has, grouped by event, with the name, command, matcher, timeout, and enabled state. If your hook is not there, stop debugging the script.
The folder is untrusted#
Folder trust is a separate gate. When trust is enabled and the current folder is untrusted, Gemini CLI runs in a restricted mode and does not load project settings. At the commit checked here, the hook registry is stricter than the wording on the trusted-folders page: it skips the settings hooks entirely, not just the project layer (the registry). After the trust choice, the only trace is a debug log line, "Project hooks disabled because the folder is not trusted", and only when debug logging is on.
Check the trust state with /permissions trust, or look at ~/.gemini/trustedFolders.json. The permissions guide covers the trust dialog and its defaults. If the folder is trusted, continue; if it is not, no amount of JSON editing will make the hook run.
Project hooks have a second layer: they are fingerprinted by name and command. A changed command, for example after a git pull, is treated as a new hook and warned about again. The warning is a notice, not a yes/no prompt; the hook still runs. Review project hooks before you trust a repository.
The matcher never matched#
This is the most common "loaded but silent" cause. The matching rules are different for the two families of event (the hooks overview):
- Lifecycle events use exact strings.
SessionStartcompares its matcher tostartup,resume, orclear.SessionEndcompares toexit,clear,logout,prompt_input_exit, orother.PreCompresscompares toautoormanual. - Tool events use regular expressions.
BeforeToolandAfterToolcompare the matcher to the tool name.write_.*matches names that start withwrite_;run_shell_commandmatches the shell tool. MCP tools are namedmcp_<server>_<tool>. - No matcher,
"", or"*"matches everything.
That difference explains a whole class of reports. A SessionStart entry with "matcher": "startup|resume|clear" is a valid regex, but lifecycle matching is a string comparison, so it matches none of the three sources. Use three separate entries, or omit the matcher. The 2026 SessionStart report above used exactly that shape.
The same trap catches migrated Claude Code hooks. Claude's SessionStart can use compact; a Gemini SessionStart at this commit only has startup, resume, and clear. The migration command copies the matcher unchanged, so that hook never fires (#14724 is the open record of the mismatch). The safe migration is to rewrite the matcher by hand.
Tool matchers are case-sensitive and use Gemini's tool names. Bash will not match run_shell_command; Edit will not match replace or write_file. The tools reference lists the built-in names.
It ran, but the CLI ignored the answer#
A hook can run and still have no effect. The CLI treats stdout as JSON, and only JSON. If the script prints a friendly line before the object, parsing fails, the CLI falls back to "allow", and the whole output is shown as a system message. A blocking hook then looks like a hook that ran and did nothing.
Keep these rules straight:
- stdout is for one JSON object. Nothing else, including a stray
echo, a progress line, or a second object. - stderr is for logs.
echo "checking" >&2is safe while stdout carries the one JSON object. The runner parses stdout first and only falls back to stderr when stdout is empty, so a hook that writes only to stderr can have that text treated as its output. - Exit
0is the normal path, including a deliberate block with{"decision": "deny", "reason": "..."}. - Exit
2is the hard block. ForBeforeToolit blocks the tool; forAfterToolit hides the tool result. Either way the turn continues. ForBeforeAgentorBeforeModelit aborts the turn. ForAfterAgentit rejects the response and triggers a retry.stderrbecomes the reason. - Any other exit code is a warning. The CLI shows it and continues with the original action. A validation script that calls
exit 1does not block anything. continue: falseis not the same asdecision: deny. Deny blocks one action;continue: falsestops the whole agent loop.
For a logging-only hook, printing {} is enough. For a BeforeTool gate, return {"decision": "deny", "reason": "..."} and make sure the script exits 0. For a case that must stop the session, use {"continue": false, "stopReason": "..."}.
Also check the mechanical parts: the script is executable, the path in command resolves, $GEMINI_PROJECT_DIR is set, and the timeout is long enough. The best-practices page has a manual test: pipe a sample JSON object into the script and check the exit code before blaming the CLI.
The event does not fire when you think it does#
Some events are conditional, and a hook that is correctly configured can still be skipped.
| Event | Fires when | Skips or caveats |
|---|---|---|
SessionStart | Startup, resume, or /clear | Not on every prompt. At this commit there is no compaction source; the sources are startup, resume, clear. |
SessionEnd | Exit, /clear, logout, prompt_input_exit, other | Best effort: the CLI does not wait for it, so a slow cleanup hook may not finish. |
BeforeAgent | After you submit a prompt, before planning | Not for subagent turns. |
AfterAgent | After a completed turn with no pending tool calls | Not after a cancel, an API error, or a turn a hook already stopped; nested calls only fire on the outermost. The retry after a stop gets stop_hook_active: true. |
PreCompress | Before context compression | Advisory: it cannot block or change the summary. |
Notification | A system alert such as a tool permission prompt | Observability only; it cannot grant or deny. |
BeforeTool / AfterTool | Around a tool call | Matcher is a regex; the tool name must be the Gemini name. |
AfterAgent deserves its own note. The docs describe it as once per turn, and the source fires it only after the turn returns without pending tools and without an error. If the agent was interrupted, hit an API error, or was stopped by another hook, no AfterAgent runs for that turn. That is enough to explain many "it never runs" reports without a code bug. The open report above is still worth checking if your turns complete normally and the hook's own log stays empty.
Subagents do not fire the per-turn lifecycle hooks. At this commit the subagent executor calls the chat directly and does not run BeforeAgent or AfterAgent for the subagent's loop (the executor). Tool calls still go through the shared scheduler, so tool hooks are the part to rely on there. A migrated Claude SubagentStop hook becomes an AfterAgent hook, which fires at the end of the main turn, not when a Gemini subagent finishes.
Prove it ran#
/hooks panel proves the hook loaded and is enabled. At v0.65.0 it is a configuration view; it does not show a run count or the last output. The docs' best-practices page says the panel shows execution counts and recent output; the shipped panel is a configuration list, so use a log for the "did it run" question.
The smallest reliable proof is to make the hook write a line itself:
#!/usr/bin/env bash
input=$(cat)
event=$(printf '%s' "$input" | jq -r '.hook_event_name')
printf '%s\t%s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$event" >> "${TMPDIR:-/tmp}/gemini-hooks.log"
printf '{}\n'Trigger the event, then read the file:
tail -f "${TMPDIR:-/tmp}/gemini-hooks.log"If the line appears, the hook ran. If it does not, the problem is earlier: load, trust, or matcher. The hook still prints valid JSON, so the log line does not break the decision.
For the CLI's own trace, set GEMINI_DEBUG_LOG_FILE before launching:
GEMINI_DEBUG_LOG_FILE=/tmp/gemini-debug.log geminiThen search for the hook planner and runner lines:
grep -E 'Created execution plan|Hook execution for|Hook execution failed' /tmp/gemini-debug.logCreated execution plan names the event and how many hooks matched. Hook execution for reports successes and failures. If there is no plan line for your event, either the event never fired or no hook matched: check the event table first, then load, trust, and matcher. If there is a plan line but no execution summary, the event started but did not finish; check for a crash or an interrupted process. The debug logger source shows the environment variable.
Finally, test the script outside the CLI with a sample object:
printf '{"session_id":"test","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' "$PWD" \
| .gemini/hooks/log.sh
echo $?That isolates a broken script from a broken configuration.
Moving a Claude Code hook over#
gemini hooks migrate --from-claude reads .claude/settings.local.json if it exists, otherwise .claude/settings.json — it does not merge the two — and merges the migrated hooks into the existing hooks in .gemini/settings.json (the migration source). The command exists at v0.65.0; it was missing in one preview build in early 2026 (#16049, closed as completed on 20 January 2026).
The mapping is not a rename-only exercise:
| Claude Code | Gemini CLI |
|---|---|
PreToolUse | BeforeTool |
PostToolUse | AfterTool |
UserPromptSubmit | BeforeAgent |
Stop | AfterAgent |
SubAgentStop | AfterAgent |
SessionStart | SessionStart |
SessionEnd | SessionEnd |
PreCompact | PreCompress |
Notification | Notification |
The migration also converts the timeout from seconds to milliseconds, replaces $CLAUDE_PROJECT_DIR with $GEMINI_PROJECT_DIR, and rewrites tool names in matchers: Edit to replace, Bash to run_shell_command, Read to read_file, Write to write_file, Glob to glob, Grep to grep, LS to ls.
Three things it does not fix:
- Lifecycle matcher strings. A
SessionStartmatcher ofcompactis copied as-is and never matches. Rewrite it forstartup,resume, orclearif a session-start hook is what you want; for a hook that was meant to run at compaction, usePreCompress, which is advisory and can log or notify but not inject context. SubagentStopsemantics. Gemini maps it toAfterAgent, which is the end of the main turn, not the end of a subagent.- Your own JSON. The migration does not rewrite the hook script. If it printed plain text to stdout under Claude Code, it will still do so here; only the event names and environment variables changed.
After migrating, open /hooks panel, confirm the event and matcher, and run the log test once. Migration is a draft, not a guarantee that the hook means the same thing.
Where to go next#
- Gemini CLI guides — the rest of the set.
- Why Gemini CLI changes models mid-session — a
BeforeModelhook can rewrite the model, and the routing page shows the other causes. - Why Gemini CLI keeps asking for permission — the policy engine is a separate enforcement layer from a hook.
- Why your Gemini CLI skill or subagent never activates — subagent discovery and the limits of lifecycle hooks inside one.
- Context compaction — what
PreCompresscan and cannot see.