Skip to main content

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:

mkdir -p .gemini/hooks
cat > .gemini/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" >> .gemini/tool-log.txt

# Return success (exit 0) - output goes to user in transcript mode
echo "Logged: $tool_name"
EOF

chmod +x .gemini/hooks/log-tools.sh

Step 2: Configure the hook

Add the hook configuration to .gemini/settings.json:

{
"hooks": {
"AfterTool": [
{
"matcher": "*",
"hooks": [
{
"name": "tool-logger",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/log-tools.sh",
"description": "Log all tool executions"
}
]
}
]
}
}

Step 3: Test your hook

Run Gemini CLI and execute any command that uses tools:

> Read the README.md file

[Agent uses ReadFile tool]

Logged: ReadFile

Check .gemini/tool-log.txt to see the logged tool executions.

Practical examples

Security: Block secrets in commits

Prevent committing files containing API keys or passwords.

.gemini/hooks/block-secrets.sh:

#!/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 2
fi

exit 0

.gemini/settings.json:

{
"hooks": {
"BeforeTool": [
{
"matcher": "WriteFile|Edit",
"hooks": [
{
"name": "secret-scanner",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/block-secrets.sh",
"description": "Prevent committing secrets"
}
]
}
]
}
}

Auto-testing after code changes

Automatically run tests when code files are modified.

.gemini/hooks/auto-test.sh:

#!/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 0
fi

# Run tests
if npx vitest run "$test_file" --silent 2>&1 | head -20; then
echo "✅ Tests passed"
else
echo "❌ Tests failed"
fi

exit 0

.gemini/settings.json:

{
"hooks": {
"AfterTool": [
{
"matcher": "WriteFile|Edit",
"hooks": [
{
"name": "auto-test",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.sh",
"description": "Run tests after code changes"
}
]
}
]
}
}

Dynamic context injection

Add relevant project context before each agent interaction.

.gemini/hooks/inject-context.sh:

#!/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

.gemini/settings.json:

{
"hooks": {
"BeforeAgent": [
{
"matcher": "*",
"hooks": [
{
"name": "git-context",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/inject-context.sh",
"description": "Inject git commit history"
}
]
}
]
}
}

Advanced features

RAG-based tool filtering

Use BeforeToolSelection to intelligently reduce the tool space based on the current task. Instead of sending all 100+ tools to the model, filter to the most relevant ~15 tools using semantic search or keyword matching.

This improves:

  • Model accuracy: Fewer similar tools reduce confusion
  • Response speed: Smaller tool space is faster to process
  • Cost efficiency: Less tokens used per request

Cross-session memory

Use SessionStart and SessionEnd hooks to maintain persistent knowledge across sessions:

  • SessionStart: Load relevant memories from previous sessions
  • AfterModel: Record important interactions during the session
  • SessionEnd: Extract learnings and store for future use

This enables the assistant to learn project conventions, remember important decisions, and share knowledge across team members.

Hook chaining

Multiple hooks for the same event run in the order declared. Each hook can build upon previous hooks' outputs:

{
"hooks": {
"BeforeAgent": [
{
"matcher": "*",
"hooks": [
{
"name": "load-memories",
"type": "command",
"command": "./hooks/load-memories.sh"
},
{
"name": "analyze-sentiment",
"type": "command",
"command": "./hooks/analyze-sentiment.sh"
}
]
}
]
}
}

Complete example: Smart Development Workflow Assistant

This comprehensive example demonstrates all hook events working together with two advanced features:

  • RAG-based tool selection: Reduces 100+ tools to ~15 relevant ones per task
  • Cross-session memory: Learns and persists project knowledge

Architecture

SessionStart → Initialize memory & index tools

BeforeAgent → Inject relevant memories

BeforeModel → Add system instructions

BeforeToolSelection → Filter tools via RAG

BeforeTool → Validate security

AfterTool → Run auto-tests

AfterModel → Record interaction

SessionEnd → Extract and store memories

Installation

Prerequisites:

  • Node.js 18+
  • Gemini CLI installed

Setup:

# Create hooks directory
mkdir -p .gemini/hooks .gemini/memory

# Install dependencies
npm install --save-dev chromadb @google/generative-ai

# Copy hook scripts (shown below)
# Make them executable
chmod +x .gemini/hooks/*.js

Configuration

.gemini/settings.json:

{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"name": "init-assistant",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/init.js",
"description": "Initialize Smart Workflow Assistant"
}
]
}
],
"BeforeAgent": [
{
"matcher": "*",
"hooks": [
{
"name": "inject-memories",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/inject-memories.js",
"description": "Inject relevant project memories"
}
]
}
],
"BeforeToolSelection": [
{
"matcher": "*",
"hooks": [
{
"name": "rag-filter",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/rag-filter.js",
"description": "Filter tools using RAG"
}
]
}
],
"BeforeTool": [
{
"matcher": "WriteFile|Edit",
"hooks": [
{
"name": "security-check",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/security.js",
"description": "Prevent committing secrets"
}
]
}
],
"AfterTool": [
{
"matcher": "WriteFile|Edit",
"hooks": [
{
"name": "auto-test",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/auto-test.js",
"description": "Run tests after code changes"
}
]
}
],
"AfterModel": [
{
"matcher": "*",
"hooks": [
{
"name": "record-interaction",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/record.js",
"description": "Record interaction for learning"
}
]
}
],
"SessionEnd": [
{
"matcher": "exit|logout",
"hooks": [
{
"name": "consolidate-memories",
"type": "command",
"command": "node $GEMINI_PROJECT_DIR/.gemini/hooks/consolidate.js",
"description": "Extract and store session learnings"
}
]
}
]
}
}

Hook scripts

1. Initialize (SessionStart)

.gemini/hooks/init.js:

#!/usr/bin/env node
const { ChromaClient } = require('chromadb');
const path = require('path');
const fs = require('fs');

async function main() {
const projectDir = process.env.GEMINI_PROJECT_DIR;
const chromaPath = path.join(projectDir, '.gemini', 'chroma');

// Ensure chroma directory exists
fs.mkdirSync(chromaPath, { recursive: true });

const client = new ChromaClient({ path: chromaPath });

// Initialize memory collection
await client.getOrCreateCollection({
name: 'project_memories',
metadata: { 'hnsw:space': 'cosine' },
});

// Count existing memories
const collection = await client.getCollection({ name: 'project_memories' });
const memoryCount = await collection.count();

console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: `Smart Workflow Assistant initialized with ${memoryCount} project memories.`,
},
systemMessage: `🧠 ${memoryCount} memories loaded`,
}),
);
}

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. Inject memories (BeforeAgent)

.gemini/hooks/inject-memories.js:

#!/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.GEMINI_API_KEY);
const model = genai.getGenerativeModel({ model: 'text-embedding-004' });
const result = await model.embedContent(prompt);

// Search memories
const projectDir = process.env.GEMINI_PROJECT_DIR;
const client = new ChromaClient({
path: path.join(projectDir, '.gemini', 'chroma'),
});

try {
const collection = await client.getCollection({ name: 'project_memories' });
const results = await collection.query({
queryEmbeddings: [result.embedding.values],
nResults: 3,
});

if (results.documents[0]?.length > 0) {
const memories = results.documents[0]
.map((doc, i) => {
const meta = results.metadatas[0][i];
return `- [${meta.category}] ${meta.summary}`;
})
.join('\n');

console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'BeforeAgent',
additionalContext: `\n## Relevant Project Context\n\n${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);

3. RAG tool filter (BeforeToolSelection)

.gemini/hooks/rag-filter.js:

#!/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 <= 20) {
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.GEMINI_API_KEY);
const model = genai.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });

const result = await model.generateContent(
`Extract 3-5 keywords describing needed tool capabilities from this request:\n\n${recentMessages}\n\nKeywords (comma-separated):`,
);

const keywords = result.response
.text()
.toLowerCase()
.split(',')
.map((k) => k.trim());

// Simple keyword-based filtering + core tools
const coreTools = ['ReadFile', 'WriteFile', 'Edit', 'RunShellCommand'];
const filtered = candidateTools.filter((tool) => {
if (coreTools.includes(tool)) return true;
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);

4. Security validation (BeforeTool)

.gemini/hooks/security.js:

#!/usr/bin/env node

const SECRET_PATTERNS = [
/api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
/password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i,
/secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i,
/AKIA[0-9A-Z]{16}/, // AWS
/ghp_[a-zA-Z0-9]{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);

5. Auto-test (AfterTool)

.gemini/hooks/auto-test.js:

#!/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: 30000,
});

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)

.gemini/hooks/record.js:

#!/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.GEMINI_PROJECT_DIR;
const sessionId = process.env.GEMINI_SESSION_ID;

const tempFile = path.join(
projectDir,
'.gemini',
'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)[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) + '\n');
}

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)

.gemini/hooks/consolidate.js:

#!/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.GEMINI_PROJECT_DIR;
const sessionId = process.env.GEMINI_SESSION_ID;

const tempFile = path.join(
projectDir,
'.gemini',
'memory',
`session-${sessionId}.jsonl`,
);

if (!fs.existsSync(tempFile)) {
console.log(JSON.stringify({}));
return;
}

// Read interactions
const interactions = fs
.readFileSync(tempFile, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line));

if (interactions.length === 0) {
fs.unlinkSync(tempFile);
console.log(JSON.stringify({}));
return;
}

// Extract memories using LLM
const genai = new GoogleGenerativeAI(process.env.GEMINI_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, 2)}

JSON:`;

try {
const result = await model.generateContent(prompt);
const text = result.response.text().replace(/```json\n?|\n?```/g, '');
const memories = JSON.parse(text);

// Store in ChromaDB
const client = new ChromaClient({
path: path.join(projectDir, '.gemini', 'chroma'),
});
const collection = await client.getCollection({ name: 'project_memories' });
const embedModel = genai.getGenerativeModel({
model: 'text-embedding-004',
});

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(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

🧠 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 127 → 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 127 → 18 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 ~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-2.0-flash-exp for intent extraction (fast, cheap)
  • Uses text-embedding-004 for RAG (inexpensive)
  • Caches tool descriptions (one-time cost)
  • Minimal overhead per request (<500ms typically)

Customization

Adjust memory relevance:

// In inject-memories.js, change nResults
const results = await collection.query({
queryEmbeddings: [result.embedding.values],
nResults: 5, // More memories
});

Modify tool filter count:

// In rag-filter.js, adjust the limit
allowedFunctionNames: filtered.slice(0, 30), // More tools

Add custom security patterns:

// In security.js, add patterns
const SECRET_PATTERNS = [
// ... existing patterns
/private[_-]?key/i,
/auth[_-]?token/i,
];

Learn more