Gemini CLI Hooks Reference
Updated by Alex Sorokoletov
Independent reference, not affiliated with Google. Tables are generated from the Gemini CLI source at v0.65.0 (v0.65.0). Official documentation: geminicli.com.
Gemini CLI runs hooks on 11 lifecycle events, from BeforeTool to BeforeToolSelection. A hook is a shell command that receives a JSON payload on stdin and can answer with JSON on stdout to block, rewrite or annotate what happens next. Hooks live in the hooks block of settings.json or in an extension's hooks/hooks.json.
Which hook events does Gemini CLI support?#
| Event | matcher compared against | What stdout JSON can do | Several hooks combine | Event-specific input |
|---|---|---|---|---|
BeforeTool | tool name (regex, unanchored) | block the call (decision block/deny), force a confirmation (decision ask), rewrite tool_input, stop the agent (continue: false) | any block wins; messages and context joined | tool_name, tool_input, mcp_context, original_request_name |
AfterTool | tool name (regex, unanchored) | replace the result (decision block/deny), add context, chain a tool (tailToolCallRequest), stop the agent (continue: false) | any block wins; messages and context joined | tool_name, tool_input, tool_response, mcp_context, original_request_name |
BeforeAgent | ignored | block the turn (decision block/deny), add context, stop the agent (continue: false) | any block wins; messages and context joined | prompt |
Notification | ignored | observe only (systemMessage, suppressOutput) | later hook's fields replace earlier ones | notification_type, message, details |
AfterAgent | ignored | send reason back as a new prompt (decision block/deny), clear context (clearContext), stop the agent (continue: false) | any block wins; messages and context joined | prompt, prompt_response, stop_hook_active |
SessionStart | source (exact match) | observe only (systemMessage, suppressOutput) | any block wins; messages and context joined | source |
SessionEnd | reason (exact match) | observe only (systemMessage, suppressOutput) | later hook's fields replace earlier ones | reason |
PreCompress | trigger (exact match) | observe only (systemMessage, suppressOutput) | later hook's fields replace earlier ones | trigger |
BeforeModel | ignored | block the call, optionally with a synthetic llm_response, rewrite llm_request, stop the agent (continue: false) | later hook's fields replace earlier ones | llm_request |
AfterModel | ignored | replace the response chunk (llm_response), block (decision block/deny), stop the agent (continue: false) | later hook's fields replace earlier ones | llm_request, llm_response |
BeforeToolSelection | ignored | restrict tools (toolConfig mode, allowedFunctionNames) | NONE wins, else ANY, else AUTO; function names unioned | llm_request |
Three events can rewrite model traffic rather than tool calls: BeforeModel edits the request or answers it with a synthetic response, AfterModel replaces each streamed response chunk, and BeforeToolSelection narrows which functions the model may call. When several hooks match one event they run in parallel and their outputs are merged as shown in the last column: for tool and agent events a single block from any hook blocks.
AfterAgent works like Claude Code's Stop hook: "decision": "block" sends the reason back to the model as a new prompt, and the retried turn arrives with stop_hook_active: true so the hook can avoid looping forever.
Where are hooks configured in settings.json?#
Hooks sit under a top-level hooks key, one array per event name. Each entry has an optional matcher and a hooks array of commands:
{
"hooks": {
"BeforeTool": [
{
"matcher": "run_shell_command|write_file",
"hooks": [
{
"type": "command",
"name": "guard-shell",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/guard.sh",
"timeout": 30000
}
]
}
]
}
}
| Key | Where | Type | Notes |
|---|---|---|---|
matcher | definition | string | Empty or * matches everything. Tool events: passed to new RegExp() and tested against the tool name, unanchored, so write the pattern without /slashes/; an invalid regex falls back to exact match. Trigger events: exact string match. |
sequential | definition | boolean | true runs every matching hook for the event one after another, each seeing the previous hook's changes; default is parallel |
type | hook | "command" | Only command hooks can be configured in settings.json. |
command | hook | string | Shell command to execute. Receives JSON input via stdin and returns JSON output via stdout. |
name | hook | string | Unique identifier for the hook. |
description | hook | string | A description of the hook. |
timeout | hook | number | Milliseconds. Default 60000 (60 s); on expiry SIGTERM, then SIGKILL 5 s later. |
env | hook | Record<string, string> | Extra environment variables for this command. |
The same hooks object can go in ~/.gemini/settings.json and in a project's .gemini/settings.json. The per-event arrays are concatenated across files, so user and project hooks both run. An extension ships the identical shape in hooks/hooks.json under a top-level "hooks" key.
A separate hooksConfig block switches the system on and off:
| Key | Type | Default | Notes |
|---|---|---|---|
hooksConfig.enabled | boolean | true | Canonical toggle for the hooks system. When disabled, no hooks will be executed. |
hooksConfig.disabled | string[] | [] | List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured. |
hooksConfig.notifications | boolean | true | Show visual indicators when hooks are executing. |
Hooks from settings.json run only in trusted folders; in an untrusted folder they are skipped. The first time a trusted project's .gemini/settings.json contains hooks you have not seen, Gemini CLI prints a warning listing them, records them in ~/.gemini/trusted_hooks.json, and runs them. It does not ask for approval first.
How does a Gemini CLI hook block a tool call?#
JSON on stdout decides first. {"decision": "deny", "reason": "..."} (or "block") from a BeforeTool hook stops the call, and the model receives Tool execution blocked: <reason>. "decision": "ask" replaces the policy engine's verdict with a confirmation prompt (a policy deny included), hookSpecificOutput.tool_input replaces the arguments, and "continue": false stops the whole agent loop with stopReason.
When the output is not JSON, Gemini CLI converts the text (stdout, or stderr when stdout is empty) by exit code:
| Exit code | Non-JSON output (stdout, else stderr) becomes | Counted as |
|---|---|---|
| 0 | decision allow, text shown as systemMessage | success |
| 1 | decision allow, systemMessage "Warning: <text>" | failure (warning, not blocking) |
| 2 or higher | decision deny, text as reason | failure |
Two consequences for plain-text hooks: exit 1 never blocks, it only shows a warning, and any exit of 2 or above blocks only when the hook printed some text to use as the reason. JSON output is honored whatever the exit code. A hook that exits 2 silently is logged as failed and the tool call proceeds. Any non-zero exit, timeout or spawn error shows "Hook(s) [name] failed for event ..." in the UI.
Every field below works on every event; hookSpecificOutput carries the event-specific fields listed further down.
| Field | Type | Notes |
|---|---|---|
continue | boolean | — |
stopReason | string | — |
suppressOutput | boolean | — |
systemMessage | string | — |
decision | HookDecision | — |
reason | string | — |
hookSpecificOutput | Record<string, unknown> | — |
Why are my Gemini CLI hooks not running?#
Six causes cover most cases. The folder is untrusted, so hooks from settings.json are skipped. hooksConfig.enabled is false, or the hook's name is listed in hooksConfig.disabled. The matcher uses a Claude Code tool name: Gemini CLI tools are named run_shell_command, write_file and replace, and the matcher is a regex tested against that name. The timeout is in milliseconds, so 30 kills the command after 30 ms instead of 30 s. The hook exits 1, which only shows a warning, or exits 2 without printing a reason, which is logged as a failure while the tool call proceeds. Or the hook is on an event that ignores the matcher and cannot block, such as Notification or SessionStart.
Example: desktop notification when Gemini CLI waits#
Notification fires with notification_type set to ToolPermission when a tool call waits for your confirmation, and passes a message and a details object on stdin. It cannot block anything, and its matcher is ignored. On macOS:
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"name": "desktop-notify",
"command": "osascript -e 'display notification \"Gemini CLI needs input\" with title \"Gemini CLI\"'"
}
]
}
]
}
}
On Linux, replace the command with notify-send "Gemini CLI" "needs input".
What environment does a hook command get?#
The command runs through the platform shell with the session's working directory as its cwd, the sanitized parent environment, the hook's own env, and these variables:
| Variable | Value | Also substituted in command text |
|---|---|---|
GEMINI_PROJECT_DIR | session working directory | yes |
GEMINI_PLANS_DIR | plans directory | yes |
GEMINI_CWD | session working directory | yes |
GEMINI_SESSION_ID | session id | yes |
CLAUDE_PROJECT_DIR | session working directory | yes |
Occurrences of $GEMINI_PROJECT_DIR, $GEMINI_CWD, $GEMINI_PLANS_DIR, $GEMINI_SESSION_ID and $CLAUDE_PROJECT_DIR in the command string are replaced with shell-escaped values before the shell sees them.
How do Gemini CLI hooks compare to Claude Code hooks?#
The model is the same (JSON on stdin, JSON on stdout, matcher plus hooks array), but the names differ and so does the timeout unit. gemini hooks migrate --from-claude reads .claude/settings.local.json (or .claude/settings.json when there is no local file), renames events and tool names in matchers, swaps $CLAUDE_PROJECT_DIR for $GEMINI_PROJECT_DIR, and writes the result to the project's .gemini/settings.json.
| Claude Code event | Gemini CLI event |
|---|---|
PreToolUse | BeforeTool |
PostToolUse | AfterTool |
UserPromptSubmit | BeforeAgent |
Stop | AfterAgent |
SubAgentStop | AfterAgent |
SessionStart | SessionStart |
SessionEnd | SessionEnd |
PreCompact | PreCompress |
Notification | Notification |
| Claude Code tool | Gemini CLI tool |
|---|---|
Edit | replace |
Bash | run_shell_command |
Read | read_file |
Write | write_file |
Glob | glob |
Grep | grep |
LS | ls |
Other differences: Gemini CLI has no PermissionRequest or SubagentStart event, exit code 1 is a warning rather than an error, and BeforeModel, AfterModel and BeforeToolSelection have no Claude Code equivalent. CLAUDE_PROJECT_DIR is still set in the hook environment, so scripts that read it keep working.
Hook input and output fields#
Every hook receives these fields on stdin, plus the event-specific fields below:
| Field | Type | Always sent | Notes |
|---|---|---|---|
session_id | string | yes | — |
transcript_path | string | yes | — |
cwd | string | yes | — |
hook_event_name | string | yes | — |
timestamp | string | yes | — |
BeforeTool#
| Field | Type | Always sent | Notes |
|---|---|---|---|
tool_name | string | yes | — |
tool_input | Record<string, unknown> | yes | — |
mcp_context | object | no | Only present for MCP tools |
mcp_context.server_name | string | yes | — |
mcp_context.tool_name | string | yes | Original tool name from the MCP server |
mcp_context.command | string | no | For stdio transport |
mcp_context.args | string[] | no | For stdio transport |
mcp_context.cwd | string | no | For stdio transport |
mcp_context.url | string | no | For SSE/HTTP transport |
mcp_context.tcp | string | no | For WebSocket transport |
original_request_name | string | no | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.tool_input | Record<string, unknown> | — |
AfterTool#
| Field | Type | Always sent | Notes |
|---|---|---|---|
tool_name | string | yes | — |
tool_input | Record<string, unknown> | yes | — |
tool_response | Record<string, unknown> | yes | — |
mcp_context | object | no | Only present for MCP tools |
mcp_context.server_name | string | yes | — |
mcp_context.tool_name | string | yes | Original tool name from the MCP server |
mcp_context.command | string | no | For stdio transport |
mcp_context.args | string[] | no | For stdio transport |
mcp_context.cwd | string | no | For stdio transport |
mcp_context.url | string | no | For SSE/HTTP transport |
mcp_context.tcp | string | no | For WebSocket transport |
original_request_name | string | no | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.additionalContext | string | — |
hookSpecificOutput.tailToolCallRequest | object | Optional request to execute another tool immediately after this one. The result of this tail call will replace the original tool's response. |
hookSpecificOutput.tailToolCallRequest.name | string | — |
hookSpecificOutput.tailToolCallRequest.args | Record<string, unknown> | — |
BeforeAgent#
| Field | Type | Always sent | Notes |
|---|---|---|---|
prompt | string | yes | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.additionalContext | string | — |
AfterAgent#
| Field | Type | Always sent | Notes |
|---|---|---|---|
prompt | string | yes | — |
prompt_response | string | yes | — |
stop_hook_active | boolean | yes | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.clearContext | boolean | — |
Notification#
| Field | Type | Always sent | Notes |
|---|---|---|---|
notification_type | ToolPermission | yes | — |
message | string | yes | — |
details | Record<string, unknown> | yes | — |
SessionStart#
| Field | Type | Always sent | Notes |
|---|---|---|---|
source | startup | resume | clear | yes | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.additionalContext | string | — |
SessionEnd#
| Field | Type | Always sent | Notes |
|---|---|---|---|
reason | exit | clear | logout | prompt_input_exit | other | yes | — |
PreCompress#
| Field | Type | Always sent | Notes |
|---|---|---|---|
trigger | manual | auto | yes | — |
BeforeModel#
| Field | Type | Always sent | Notes |
|---|---|---|---|
llm_request | LLMRequest | yes | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.llm_request | Partial<LLMRequest> | — |
hookSpecificOutput.llm_response | LLMResponse | — |
AfterModel#
| Field | Type | Always sent | Notes |
|---|---|---|---|
llm_request | LLMRequest | yes | — |
llm_response | LLMResponse | yes | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.llm_response | Partial<LLMResponse> | — |
BeforeToolSelection#
| Field | Type | Always sent | Notes |
|---|---|---|---|
llm_request | LLMRequest | yes | — |
| Field | Type | Notes |
|---|---|---|
hookSpecificOutput.toolConfig | object | — |
hookSpecificOutput.toolConfig.mode | 'AUTO' | 'ANY' | 'NONE' | — |
hookSpecificOutput.toolConfig.allowedFunctionNames | string[] | — |
Frequently asked questions#
Does Gemini CLI support hooks?#
Yes. Gemini CLI has 11 hook events, including BeforeTool, AfterTool, BeforeAgent, AfterAgent, SessionStart, SessionEnd, PreCompress and Notification, plus three model-level events. Hooks are shell commands configured in the hooks block of settings.json or in an extension's hooks/hooks.json, and the system is on by default through hooksConfig.enabled.
Why are my Gemini CLI hooks not working?#
The usual causes: the folder is not trusted, so settings hooks are skipped; hooksConfig.enabled is false or the hook's name is in hooksConfig.disabled; the matcher is written as /pattern/, which the regex treats as literal slashes; the timeout was set in seconds (30 means 30 ms); or stdout mixes log lines with JSON, so the whole output is read as plain text.
Is the Gemini CLI hook timeout in seconds or milliseconds?#
Milliseconds. The default is 60000 (60 seconds). When it expires Gemini CLI sends SIGTERM, then SIGKILL five seconds later, and reports the hook as failed. Hooks converted with gemini hooks migrate --from-claude keep Claude Code's value unchanged, so a Claude timeout of 30 seconds becomes 30 milliseconds unless you multiply it by 1000.
How do I send a notification when Gemini CLI needs input?#
Add a Notification hook. It fires with notification_type set to ToolPermission when a tool call is waiting for your confirmation, along with a message and a details object describing the call. The matcher is ignored for this event and the hook cannot change the outcome, so it suits desktop alerts, sounds or chat pings.
Can a Gemini CLI hook block a tool call?#
Yes, from BeforeTool. Print {"decision": "deny", "reason": "..."} and the call is skipped with Tool execution blocked: <reason>. Plain text with exit code 2 or higher also blocks, using the text as the reason; plain text with exit code 1 only produces a warning. "decision": "ask" forces a confirmation instead of blocking outright.
How do Gemini CLI hooks compare to Claude Code hooks?#
The configuration shape and stdin/stdout JSON contract match, but the names differ: PreToolUse is BeforeTool, PostToolUse is AfterTool, UserPromptSubmit is BeforeAgent, Stop is AfterAgent, PreCompact is PreCompress. Matchers use Gemini tool names such as run_shell_command and replace, and timeouts are milliseconds. gemini hooks migrate --from-claude converts an existing Claude Code configuration.
Do project hooks run without asking?#
Yes, in a trusted folder. New hooks in a project's .gemini/settings.json trigger a one-time warning that lists them, are then recorded in ~/.gemini/trusted_hooks.json, and run without an approval prompt. In an untrusted folder no hooks from settings.json run at all, so trusting a folder also means trusting its hooks.
Sources
- Gemini CLI official documentation
- Gemini CLI release notes
packages/core/src/hooks/types.ts: HookEventName enum, hook input/output interfaces (at v0.65.0)packages/core/src/hooks/hookTranslator.ts: HookToolConfig and LLM request/response shapes (at v0.65.0)packages/core/src/hooks/hookEventHandler.ts: event firing and matcher context (at v0.65.0)packages/core/src/hooks/hookRunner.ts: command execution, timeout, exit codes, environment (at v0.65.0)packages/core/src/hooks/hookAggregator.ts: per-event output merge strategies (at v0.65.0)packages/core/src/scheduler/hook-utils.ts: BeforeTool effects (at v0.65.0)packages/core/src/core/coreToolHookTriggers.ts: AfterTool effects (at v0.65.0)packages/core/src/core/client.ts: BeforeAgent/AfterAgent effects (at v0.65.0)packages/core/src/hooks/hookSystem.ts: BeforeModel/AfterModel effects (at v0.65.0)schemas/settings.schema.json: generated settings.json schema (hooks, hooksConfig) (at v0.65.0)packages/core/src/hooks/hookPlanner.ts: matcher and execution plan (at v0.65.0)packages/core/src/hooks/hookRegistry.ts: hook loading, trust gate, event name validation (at v0.65.0)packages/cli/src/commands/hooks/migrate.ts: gemini hooks migrate --from-claude (at v0.65.0)
Release-by-release changes: Gemini CLI version tracker. All Gemini CLI pages: Gemini CLI reference index.