2025-12-03 10:57:05 -08:00
# Writing hooks for Gemini CLI
This guide will walk you through creating hooks for Gemini CLI, from a simple
2026-01-21 22:13:15 -05:00
logging hook to a comprehensive workflow assistant.
2025-12-03 10:57:05 -08:00
## Prerequisites
Before you start, make sure you have:
- Gemini CLI installed and configured
- Basic understanding of shell scripting or JavaScript/Node.js
- Familiarity with JSON for hook input/output
## Quick start
Let's create a simple hook that logs all tool executions to understand the
basics.
2026-01-21 22:13:15 -05:00
**Crucial Rule:** Always write logs to `stderr` . Write only the final JSON to
`stdout` .
2025-12-03 10:57:05 -08:00
### Step 1: Create your hook script
2026-01-21 22:13:15 -05:00
Create a directory for hooks and a simple logging script.
> **Note**:
>
> This example uses `jq` to parse JSON. If you don't have it installed, you can
> perform similar logic using Node.js or Python.
2025-12-03 10:57:05 -08:00
2026-02-27 15:41:47 -08:00
**macOS/Linux**
2025-12-03 10:57:05 -08:00
```bash
mkdir -p .gemini/hooks
cat > .gemini/hooks/log-tools.sh << 'EOF'
#!/usr/bin/env bash
# Read hook input from stdin
input=$(cat)
2026-01-21 22:13:15 -05:00
# Extract tool name (requires jq)
2025-12-03 10:57:05 -08:00
tool_name=$(echo "$input" | jq -r '.tool_name')
2026-01-21 22:13:15 -05:00
# Log to stderr (visible in terminal if hook fails, or captured in logs)
echo "Logging tool: $tool_name" >&2
2025-12-03 10:57:05 -08:00
# Log to file
echo "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt
2026-01-21 22:13:15 -05:00
# Return success (exit 0) with empty JSON
echo "{}"
exit 0
2025-12-03 10:57:05 -08:00
EOF
chmod +x .gemini/hooks/log-tools.sh
```
2026-02-27 15:41:47 -08:00
**Windows (PowerShell)**
```powershell
New-Item -ItemType Directory -Force -Path ".gemini\hooks"
@"
# Read hook input from stdin
`$inputJson = ` $input | Out-String | ConvertFrom-Json
# Extract tool name
`$toolName = ` $inputJson.tool_name
# Log to stderr (visible in terminal if hook fails, or captured in logs)
[Console]::Error.WriteLine("Logging tool: `$toolName")
# Log to file
"[`$(Get-Date -Format 'o')] Tool executed: ` $toolName" | Out-File -FilePath ".gemini\tool-log.txt" -Append -Encoding utf8
# Return success with empty JSON
"{}"
"@ | Out-File -FilePath ".gemini\hooks\log-tools.ps1" -Encoding utf8
```
2026-01-21 22:13:15 -05:00
## Exit Code Strategies
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
There are two ways to control or block an action in Gemini CLI:
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
| Strategy | Exit Code | Implementation | Best For |
| :------------------------- | :-------- | :----------------------------------------------------------------- | :---------------------------------------------------------- |
| **Structured (Idiomatic) ** | `0` | Return a JSON object like `{"decision": "deny", "reason": "..."}` . | Production hooks, custom user feedback, and complex logic. |
| **Emergency Brake ** | `2` | Print the error message to `stderr` and exit. | Simple security gates, script errors, or rapid prototyping. |
2025-12-03 10:57:05 -08:00
## Practical examples
### Security: Block secrets in commits
2026-01-21 22:13:15 -05:00
Prevent committing files containing API keys or passwords. Note that we use
**Exit Code 0** to provide a structured denial message to the agent.
2025-12-03 10:57:05 -08:00
**`.gemini/hooks/block-secrets.sh` :**
```bash
#!/usr/bin/env bash
input=$(cat)
# Extract content being written
content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')
# Check for secrets
if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then
2026-01-21 22:13:15 -05:00
# Log to stderr
echo "Blocked potential secret" >&2
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
# Return structured denial to stdout
cat <<EOF
2025-12-03 10:57:05 -08:00
{
2026-01-21 22:13:15 -05:00
"decision": "deny",
"reason": "Security Policy: Potential secret detected in content.",
"systemMessage": "🔒 Security scanner blocked operation"
2025-12-03 10:57:05 -08:00
}
2026-01-21 22:13:15 -05:00
EOF
2025-12-03 10:57:05 -08:00
exit 0
fi
2026-01-21 22:13:15 -05:00
# Allow
echo '{"decision": "allow"}'
2025-12-03 10:57:05 -08:00
exit 0
```
2026-01-21 22:13:15 -05:00
### Dynamic context injection (Git History)
2025-12-03 10:57:05 -08:00
Add relevant project context before each agent interaction.
**`.gemini/hooks/inject-context.sh` :**
```bash
#!/usr/bin/env bash
# Get recent git commits for context
context=$(git log -5 --oneline 2>/dev/null || echo "No git history")
# Return as JSON
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "BeforeAgent",
"additionalContext": "Recent commits:\n$context"
}
}
EOF
```
2026-01-21 22:13:15 -05:00
### RAG-based Tool Filtering (BeforeToolSelection)
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
Use `BeforeToolSelection` to intelligently reduce the tool space. This example
uses a Node.js script to check the user's prompt and allow only relevant tools.
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
**`.gemini/hooks/filter-tools.js` :**
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
```javascript
#!/usr/bin/env node
const fs = require('fs');
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
async function main() {
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const { llm_request } = input;
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
// Decoupled API: Access messages from llm_request
const messages = llm_request.messages || [];
const lastUserMessage = messages
.slice()
.reverse()
.find((m) => m.role === 'user');
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
if (!lastUserMessage) {
console.log(JSON.stringify({})); // Do nothing
return;
}
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
const text = lastUserMessage.content;
const allowed = ['write_todos']; // Always allow memory
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
// Simple keyword matching
if (text.includes('read') || text.includes('check')) {
allowed.push('read_file', 'list_directory');
}
if (text.includes('test')) {
allowed.push('run_shell_command');
}
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
// If we found specific intent, filter tools. Otherwise allow all.
if (allowed.length > 1) {
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'BeforeToolSelection',
toolConfig: {
mode: 'ANY', // Force usage of one of these tools (or AUTO)
allowedFunctionNames: allowed,
},
},
}),
);
} else {
console.log(JSON.stringify({}));
}
}
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
**`.gemini/settings.json` :**
2025-12-03 10:57:05 -08:00
```json
{
"hooks": {
2026-01-21 22:13:15 -05:00
"BeforeToolSelection": [
2025-12-03 10:57:05 -08:00
{
"matcher": "*",
"hooks": [
{
2026-01-21 22:13:15 -05:00
"name": "intent-filter",
2026-01-25 18:33:12 -05:00
"type": "command",
2026-01-21 22:13:15 -05:00
"command": "node .gemini/hooks/filter-tools.js"
2025-12-03 10:57:05 -08:00
}
]
}
]
}
}
```
2026-01-21 22:13:15 -05:00
> **TIP**
>
> **Union Aggregation Strategy**: `BeforeToolSelection` is unique in that it
> combines the results of all matching hooks. If you have multiple filtering
> hooks, the agent will receive the **union** of all whitelisted tools. Only
> using `mode: "NONE"` will override other hooks to disable all tools.
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
## Complete example: Smart Development Workflow Assistant
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
This comprehensive example demonstrates all hook events working together. We
will build a system that maintains memory, filters tools, and checks for
security.
2025-12-03 10:57:05 -08:00
### Architecture
2026-01-21 22:13:15 -05:00
1. **SessionStart ** : Load project memories.
2. **BeforeAgent ** : Inject memories into context.
3. **BeforeToolSelection ** : Filter tools based on intent.
4. **BeforeTool ** : Scan for secrets.
5. **AfterModel ** : Record interactions.
6. **AfterAgent ** : Validate final response quality (Retry).
7. **SessionEnd ** : Consolidate memories.
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
### Configuration (`.gemini/settings.json`)
2025-12-03 10:57:05 -08:00
```json
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
2026-01-25 18:33:12 -05:00
"hooks": [
{
"name": "init",
"type": "command",
"command": "node .gemini/hooks/init.js"
}
]
2025-12-03 10:57:05 -08:00
}
],
"BeforeAgent": [
{
"matcher": "*",
"hooks": [
{
2026-01-21 22:13:15 -05:00
"name": "memory",
2026-01-25 18:33:12 -05:00
"type": "command",
2026-01-21 22:13:15 -05:00
"command": "node .gemini/hooks/inject-memories.js"
2025-12-03 10:57:05 -08:00
}
]
}
],
"BeforeToolSelection": [
{
"matcher": "*",
"hooks": [
2026-01-25 18:33:12 -05:00
{
"name": "filter",
"type": "command",
"command": "node .gemini/hooks/rag-filter.js"
}
2025-12-03 10:57:05 -08:00
]
}
],
"BeforeTool": [
{
2026-01-21 22:13:15 -05:00
"matcher": "write_file",
2025-12-03 10:57:05 -08:00
"hooks": [
2026-01-25 18:33:12 -05:00
{
"name": "security",
"type": "command",
"command": "node .gemini/hooks/security.js"
}
2025-12-03 10:57:05 -08:00
]
}
],
2026-01-21 22:13:15 -05:00
"AfterModel": [
2025-12-03 10:57:05 -08:00
{
2026-01-21 22:13:15 -05:00
"matcher": "*",
2025-12-03 10:57:05 -08:00
"hooks": [
2026-01-25 18:33:12 -05:00
{
"name": "record",
"type": "command",
"command": "node .gemini/hooks/record.js"
}
2025-12-03 10:57:05 -08:00
]
}
],
2026-01-21 22:13:15 -05:00
"AfterAgent": [
2025-12-03 10:57:05 -08:00
{
"matcher": "*",
"hooks": [
2026-01-25 18:33:12 -05:00
{
"name": "validate",
"type": "command",
"command": "node .gemini/hooks/validate.js"
}
2025-12-03 10:57:05 -08:00
]
}
],
"SessionEnd": [
{
2026-01-21 22:13:15 -05:00
"matcher": "exit",
2025-12-03 10:57:05 -08:00
"hooks": [
2026-01-25 18:33:12 -05:00
{
"name": "save",
"type": "command",
"command": "node .gemini/hooks/consolidate.js"
}
2025-12-03 10:57:05 -08:00
]
}
]
}
}
```
2026-01-21 22:13:15 -05:00
### Hook Scripts
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
> **Note**: For brevity, these scripts use `console.error` for logging and
> standard `console.log` for JSON output.
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
#### 1. Initialize (`init.js`)
2025-12-03 10:57:05 -08:00
```javascript
#!/usr/bin/env node
2026-01-21 22:13:15 -05:00
// Initialize DB or resources
console.error('Initializing assistant...');
// Output to user
console.log(
JSON.stringify({
systemMessage: '🧠 Smart Assistant Loaded',
}),
);
2025-12-03 10:57:05 -08:00
```
2026-01-21 22:13:15 -05:00
#### 2. Inject Memories (`inject-memories.js`)
2025-12-03 10:57:05 -08:00
```javascript
#!/usr/bin/env node
2026-01-21 22:13:15 -05:00
const fs = require('fs');
2025-12-03 10:57:05 -08:00
async function main() {
2026-01-21 22:13:15 -05:00
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
// Assume we fetch memories from a DB here
const memories = '- [Memory] Always use TypeScript for this project.';
2025-12-03 10:57:05 -08:00
console.log(
JSON.stringify({
hookSpecificOutput: {
2026-01-21 22:13:15 -05:00
hookEventName: 'BeforeAgent',
additionalContext: `\n## Relevant Memories\n${memories}` ,
2025-12-03 10:57:05 -08:00
},
}),
);
}
2026-01-21 22:13:15 -05:00
main();
2025-12-03 10:57:05 -08:00
```
2026-01-21 22:13:15 -05:00
#### 3. Security Check (`security.js`)
2025-12-03 10:57:05 -08:00
```javascript
#!/usr/bin/env node
const fs = require('fs');
2026-01-21 22:13:15 -05:00
const input = JSON.parse(fs.readFileSync(0));
const content = input.tool_input.content || '';
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
if (content.includes('SECRET_KEY')) {
console.log(
JSON.stringify({
decision: 'deny',
reason: 'Found SECRET_KEY in content',
systemMessage: '🚨 Blocked sensitive commit',
}),
);
process.exit(0);
2025-12-03 10:57:05 -08:00
}
2026-01-21 22:13:15 -05:00
console.log(JSON.stringify({ decision: 'allow' }));
2025-12-03 10:57:05 -08:00
```
2026-01-21 22:13:15 -05:00
#### 4. Record Interaction (`record.js`)
2025-12-03 10:57:05 -08:00
```javascript
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
2026-01-21 22:13:15 -05:00
const input = JSON.parse(fs.readFileSync(0));
const { llm_request, llm_response } = input;
const logFile = path.join(
process.env.GEMINI_PROJECT_DIR,
'.gemini/memory/session.jsonl',
);
fs.appendFileSync(
logFile,
JSON.stringify({
request: llm_request,
response: llm_response,
timestamp: new Date().toISOString(),
}) + '\n',
);
console.log(JSON.stringify({}));
2025-12-03 10:57:05 -08:00
```
2026-01-21 22:13:15 -05:00
#### 5. Validate Response (`validate.js`)
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
```javascript
2025-12-03 10:57:05 -08:00
#!/usr/bin/env node
const fs = require('fs');
2026-01-21 22:13:15 -05:00
const input = JSON.parse(fs.readFileSync(0));
const response = input.prompt_response;
2025-12-03 10:57:05 -08:00
2026-01-21 22:13:15 -05:00
// Example: Check if the agent forgot to include a summary
if (!response.includes('Summary:')) {
console.log(
JSON.stringify({
decision: 'block', // Triggers an automatic retry turn
reason: 'Your response is missing a Summary section. Please add one.',
systemMessage: '🔄 Requesting missing summary...',
}),
2025-12-03 10:57:05 -08:00
);
2026-01-21 22:13:15 -05:00
process.exit(0);
2025-12-03 10:57:05 -08:00
}
2026-01-21 22:13:15 -05:00
console.log(JSON.stringify({ decision: 'allow' }));
2025-12-03 10:57:05 -08:00
```
2026-01-21 22:13:15 -05:00
#### 6. Consolidate Memories (`consolidate.js`)
2025-12-03 10:57:05 -08:00
```javascript
2026-01-21 22:13:15 -05:00
#!/usr/bin/env node
// Logic to save final session state
console.error('Consolidating memories for session end...');
2025-12-03 10:57:05 -08:00
```
2026-01-07 15:46:44 -05:00
## Packaging as an extension
2026-01-21 22:13:15 -05:00
While project-level hooks are great for specific repositories, you can share
your hooks across multiple projects by packaging them as a
[Gemini CLI extension ](https://www.google.com/search?q=../extensions/index.md ).
This provides version control, easy distribution, and centralized management.