Writing hooks for Gemini CLI
This guide will walk you through creating hooks for Gemini CLI, from a simple logging hook to a comprehensive workflow assistant.
Prerequisites
Section titled “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
Section titled “Quick start”Let’s create a simple hook that logs all tool executions to understand the basics.
Crucial Rule: Always write logs to stderr. Write only the final JSON to
stdout.
Step 1: Create your hook script
Section titled “Step 1: Create your hook script”Create a directory for hooks and a simple logging script.
Note:
This example uses
jqto parse JSON. If you don’t have it installed, you can perform similar logic using Node.js or Python.
mkdir -p .gemini/hookscat > .gemini/hooks/log-tools.sh << 'EOF'#!/usr/bin/env bash# Read hook input from stdininput=$(cat)
# Extract tool name (requires jq)tool_name=$(echo "$input" | jq -r '.tool_name')
# Log to stderr (visible in terminal if hook fails, or captured in logs)echo "Logging tool: $tool_name" >&2
# Log to fileecho "[$(date)] Tool executed: $tool_name" >> .gemini/tool-log.txt
# Return success (exit 0) with empty JSONecho "{}"exit 0EOF
chmod +x .gemini/hooks/log-tools.shExit Code Strategies
Section titled “Exit Code Strategies”There are two ways to control or block an action in Gemini CLI:
| 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. |
Practical examples
Section titled “Practical examples”Security: Block secrets in commits
Section titled “Security: Block secrets in commits”Prevent committing files containing API keys or passwords. Note that we use Exit Code 0 to provide a structured denial message to the agent.
.gemini/hooks/block-secrets.sh:
#!/usr/bin/env bashinput=$(cat)
# Extract content being writtencontent=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')
# Check for secretsif echo "$content" | grep -qE 'api[_-]?key|password|secret'; then # Log to stderr echo "Blocked potential secret" >&2
# Return structured denial to stdout cat <<EOF{ "decision": "deny", "reason": "Security Policy: Potential secret detected in content.", "systemMessage": "🔒 Security scanner blocked operation"}EOF exit 0fi
# Allowecho '{"decision": "allow"}'exit 0Dynamic context injection (Git History)
Section titled “Dynamic context injection (Git History)”Add relevant project context before each agent interaction.
.gemini/hooks/inject-context.sh:
#!/usr/bin/env bash
# Get recent git commits for contextcontext=$(git log -5 --oneline 2>/dev/null || echo "No git history")
# Return as JSONcat <<EOF{ "hookSpecificOutput": { "hookEventName": "BeforeAgent", "additionalContext": "Recent commits:\n$context" }}EOFRAG-based Tool Filtering (BeforeToolSelection)
Section titled “RAG-based Tool Filtering (BeforeToolSelection)”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.
.gemini/hooks/filter-tools.js:
#!/usr/bin/env nodeconst fs = require('fs');
async function main() { const input = JSON.parse(fs.readFileSync(0, 'utf-8')); const { llm_request } = input;
// Decoupled API: Access messages from llm_request const messages = llm_request.messages || []; const lastUserMessage = messages .slice() .reverse() .find((m) => m.role === 'user');
if (!lastUserMessage) { console.log(JSON.stringify({})); // Do nothing return; }
const text = lastUserMessage.content; const allowed = ['write_todos']; // Always allow memory
// 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'); }
// 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({})); }}
main().catch((err) => { console.error(err); process.exit(1);});.gemini/settings.json:
{ "hooks": { "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { "name": "intent-filter", "type": "command", "command": "node .gemini/hooks/filter-tools.js" } ] } ] }}TIP
Union Aggregation Strategy:
BeforeToolSelectionis 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 usingmode: "NONE"will override other hooks to disable all tools.
Complete example: Smart Development Workflow Assistant
Section titled “Complete example: Smart Development Workflow Assistant”This comprehensive example demonstrates all hook events working together. We will build a system that maintains memory, filters tools, and checks for security.
Architecture
Section titled “Architecture”- SessionStart: Load project memories.
- BeforeAgent: Inject memories into context.
- BeforeToolSelection: Filter tools based on intent.
- BeforeTool: Scan for secrets.
- AfterModel: Record interactions.
- AfterAgent: Validate final response quality (Retry).
- SessionEnd: Consolidate memories.
Configuration (.gemini/settings.json)
Section titled “Configuration (.gemini/settings.json)”{ "hooks": { "SessionStart": [ { "matcher": "startup", "hooks": [ { "name": "init", "type": "command", "command": "node .gemini/hooks/init.js" } ] } ], "BeforeAgent": [ { "matcher": "*", "hooks": [ { "name": "memory", "type": "command", "command": "node .gemini/hooks/inject-memories.js" } ] } ], "BeforeToolSelection": [ { "matcher": "*", "hooks": [ { "name": "filter", "type": "command", "command": "node .gemini/hooks/rag-filter.js" } ] } ], "BeforeTool": [ { "matcher": "write_file", "hooks": [ { "name": "security", "type": "command", "command": "node .gemini/hooks/security.js" } ] } ], "AfterModel": [ { "matcher": "*", "hooks": [ { "name": "record", "type": "command", "command": "node .gemini/hooks/record.js" } ] } ], "AfterAgent": [ { "matcher": "*", "hooks": [ { "name": "validate", "type": "command", "command": "node .gemini/hooks/validate.js" } ] } ], "SessionEnd": [ { "matcher": "exit", "hooks": [ { "name": "save", "type": "command", "command": "node .gemini/hooks/consolidate.js" } ] } ] }}Hook Scripts
Section titled “Hook Scripts”Note: For brevity, these scripts use
console.errorfor logging and standardconsole.logfor JSON output.
1. Initialize (init.js)
Section titled “1. Initialize (init.js)”#!/usr/bin/env node// Initialize DB or resourcesconsole.error('Initializing assistant...');
// Output to userconsole.log( JSON.stringify({ systemMessage: '🧠 Smart Assistant Loaded', }),);2. Inject Memories (inject-memories.js)
Section titled “2. Inject Memories (inject-memories.js)”#!/usr/bin/env nodeconst fs = require('fs');
async function main() { 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.';
console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'BeforeAgent', additionalContext: `\n## Relevant Memories\n${memories}`, }, }), );}main();3. Security Check (security.js)
Section titled “3. Security Check (security.js)”#!/usr/bin/env nodeconst fs = require('fs');const input = JSON.parse(fs.readFileSync(0));const content = input.tool_input.content || '';
if (content.includes('SECRET_KEY')) { console.log( JSON.stringify({ decision: 'deny', reason: 'Found SECRET_KEY in content', systemMessage: '🚨 Blocked sensitive commit', }), ); process.exit(0);}
console.log(JSON.stringify({ decision: 'allow' }));4. Record Interaction (record.js)
Section titled “4. Record Interaction (record.js)”#!/usr/bin/env nodeconst fs = require('fs');const path = require('path');
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({}));5. Validate Response (validate.js)
Section titled “5. Validate Response (validate.js)”#!/usr/bin/env nodeconst fs = require('fs');const input = JSON.parse(fs.readFileSync(0));const response = input.prompt_response;
// Example: Check if the agent forgot to include a summaryif (!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...', }), ); process.exit(0);}
console.log(JSON.stringify({ decision: 'allow' }));6. Consolidate Memories (consolidate.js)
Section titled “6. Consolidate Memories (consolidate.js)”#!/usr/bin/env node// Logic to save final session stateconsole.error('Consolidating memories for session end...');Packaging as an extension
Section titled “Packaging as an extension”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. This provides version control, easy distribution, and centralized management.