Add Gmail integration to Gandalf. Can be configured as a tool (agent reads/sends emails when requested from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Add Gmail integration to Gandalf. Can be configured as a tool (agent reads/sends emails when requested from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
Add Gmail Integration
This skill adds Gmail capabilities to Gandalf. It can be configured in two modes:
Tool Mode - Agent can read/send emails, but only when requested from WhatsApp
Channel Mode - Emails can trigger the agent, schedule tasks, and receive email replies
Initial Questions
Ask the user:
How do you want to use Gmail with Gandalf?
Option 1: Tool Mode
Agent can read and send emails when you ask it to
Requested only from WhatsApp (e.g., "check my email" or "send an email to...")
Simpler setup, no email polling
Option 2: Channel Mode
Everything in Tool Mode, plus:
Emails to a specific address/label trigger the agent
Agent replies via email (not WhatsApp)
Can schedule tasks via email
Requires email polling infrastructure
Store their choice and proceed to the appropriate section.
Prerequisites (Both Modes)
1. Check Existing Gmail Setup
First, check if Gmail is already configured:
ls -la ~/.gmail-mcp/ 2>/dev/null || echo"No Gmail config found"
If credentials.json exists, skip to "Verify Gmail Access" below.
2. Create Gmail Config Directory
-p ~/.gmail-mcp
mkdir
3. GCP Project Setup
USER ACTION REQUIRED
Tell the user:
I need you to set up Google Cloud OAuth credentials. I'll walk you through it:
If user pastes the JSON content, write it directly:
cat > ~/.gmail-mcp/gcp-oauth.keys.json << 'EOF'
{paste the JSON here}
EOF
Verify the file is valid JSON:
cat ~/.gmail-mcp/gcp-oauth.keys.json | head -5
4. OAuth Authorization
USER ACTION REQUIRED
Tell the user:
I'm going to run the Gmail authorization. A browser window will open asking you to sign in to Google and grant access.
Important: If you see a warning that the app isn't verified, click "Advanced" then "Go to [app name] (unsafe)" - this is normal for personal OAuth apps.
Run the authorization:
bunx @gongrzhe/server-gmail-autoauth-mcp auth
If that doesn't work (some versions don't have an auth subcommand), run it and let it prompt:
Complete the authorization in your browser. The window should close automatically when done. Let me know when you've authorized.
5. Verify Gmail Access
Check that credentials were saved:
if [ -f ~/.gmail-mcp/credentials.json ]; thenecho"Gmail authorization successful!"ls -la ~/.gmail-mcp/
elseecho"ERROR: credentials.json not found - authorization may have failed"fi
Test the connection by listing labels (quick sanity check):
Append to groups/CLAUDE.md (the global memory file):
## Email (Gmail)
You have access to Gmail via MCP tools:
-`mcp__gmail__search_emails` - Search emails with query
-`mcp__gmail__get_email` - Get full email content by ID
-`mcp__gmail__send_email` - Send an email
-`mcp__gmail__draft_email` - Create a draft
-`mcp__gmail__list_labels` - List available labels
Example: "Check my unread emails from today" or "Send an email to john@example.com about the meeting"
Also append the same section to groups/main/CLAUDE.md.
Step 3: Rebuild and Restart
Run this command:
bun run build
Wait for TypeScript compilation, then restart the service:
launchctl kickstart -k gui/$(id -u)/com.gandalf
Check that it started:
sleep 2 && launchctl list | grep gandalf
Step 5: Test Gmail Integration
Tell the user:
Gmail integration is set up! Test it by sending this message in your WhatsApp main channel:
check my recent emails
Or:
list my Gmail labels
Watch the logs for any errors:
tail -f logs/gandalf.log
Channel Mode Implementation
Channel Mode includes everything from Tool Mode, plus email polling and routing.
Read src/config.ts and add this configuration (customize values based on user's earlier answers):
exportconstEMAIL_CHANNEL: EmailChannelConfig = {
enabled: true,
triggerMode: 'label', // or 'address' or 'subject'triggerValue: 'Gandalf', // the label name, address pattern, or prefixcontextMode: 'thread',
pollIntervalMs: 60000, // Check every minutereplyPrefix: '[Andy] '
};
Step 3: Add Email State Tracking
Read src/db.ts and add these functions for tracking processed emails:
// Track processed emails to avoid duplicatesexportfunctioninitEmailTable(): void {
db.exec(`
CREATE TABLE IF NOT EXISTS processed_emails (
message_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
sender TEXT NOT NULL,
subject TEXT,
processed_at TEXT NOT NULL,
response_sent INTEGER DEFAULT 0
)
`);
}
exportfunctionisEmailProcessed(messageId: string): boolean {
const row = db.prepare('SELECT 1 FROM processed_emails WHERE message_id = ?').get(messageId);
return !!row;
}
exportfunctionmarkEmailProcessed(messageId: string, threadId: string, sender: string, subject: string): void {
db.prepare(`
INSERT OR REPLACE INTO processed_emails (message_id, thread_id, sender, subject, processed_at)
VALUES (?, ?, ?, ?, ?)
`).run(messageId, threadId, sender, subject, newDate().toISOString());
}
exportfunctionmarkEmailResponded(messageId: string): void {
db.prepare('UPDATE processed_emails SET response_sent = 1 WHERE message_id = ?').run(messageId);
}
Also find the initDatabase() function in src/db.ts and add a call to initEmailTable().
Step 4: Create Email Channel Module
Create a new file src/email-channel.ts with this content:
import { EMAIL_CHANNEL } from'./config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from'./db.js';
import pino from'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } }
});
interfaceEmailMessage {
id: string;
threadId: string;
from: string;
subject: string;
body: string;
date: string;
}
// Gmail MCP client functions (call via subprocess or import the MCP directly)// These would invoke the Gmail MCP toolsexportasyncfunctioncheckForNewEmails(): Promise<EmailMessage[]> {
// Build query based on trigger modeletquery: string;
switch (EMAIL_CHANNEL.triggerMode) {
case'label':
query = `label:${EMAIL_CHANNEL.triggerValue} is:unread`;
break;
case'address':
query = `to:${EMAIL_CHANNEL.triggerValue} is:unread`;
break;
case'subject':
query = `subject:${EMAIL_CHANNEL.triggerValue} is:unread`;
break;
}
// This requires calling Gmail MCP's search_emails tool// Implementation depends on how you want to invoke MCP from Node// Option 1: Use @anthropic-ai/claude-agent-sdk with just gmail MCP// Option 2: Run bunx gmail MCP as subprocess and parse output// Option 3: Import gmail-autoauth-mcp directly// Placeholder - implement based on preferencereturn [];
}
exportasyncfunctionsendEmailReply(threadId: string,
to: string,
subject: string,
body: string): Promise<void> {
// Call Gmail MCP's send_email tool with in_reply_to for threading// Prefix subject with replyPrefix if configuredconst replySubject = subject.startsWith('Re:')
? subject
: `Re: ${subject}`;
const prefixedBody = EMAIL_CHANNEL.replyPrefix
? `${EMAIL_CHANNEL.replyPrefix}${body}`
: body;
// Implementation: invoke Gmail MCP send_email
}
exportfunctiongetContextKey(email: EmailMessage): string {
switch (EMAIL_CHANNEL.contextMode) {
case'thread':
return`email-thread-${email.threadId}`;
case'sender':
return`email-sender-${email.from.toLowerCase()}`;
case'single':
return'email-main';
}
}
Step 5: Add Email Polling to Main Loop
Read src/index.ts and add the email polling infrastructure. First, add these imports at the top:
import { checkForNewEmails, sendEmailReply, getContextKey } from'./email-channel.js';
import { EMAIL_CHANNEL } from'./config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from'./db.js';
asyncfunctionstartEmailLoop(): Promise<void> {
if (!EMAIL_CHANNEL.enabled) {
logger.info('Email channel disabled');
return;
}
logger.info(`Email channel running (trigger: ${EMAIL_CHANNEL.triggerMode}:${EMAIL_CHANNEL.triggerValue})`);
while (true) {
try {
const emails = awaitcheckForNewEmails();
for (const email of emails) {
if (isEmailProcessed(email.id)) continue;
logger.info({ from: email.from, subject: email.subject }, 'Processing email');
markEmailProcessed(email.id, email.threadId, email.from, email.subject);
// Determine which group/context to useconst contextKey = getContextKey(email);
// Build prompt with email contentconst prompt = `<email>
<from>${email.from}</from>
<subject>${email.subject}</subject>
<body>${email.body}</body>
</email>
Respond to this email. Your response will be sent as an email reply.`;
// Run agent with email context// You'll need to create a registered group for email or use a special handlerconst response = awaitrunEmailAgent(contextKey, prompt, email);
if (response) {
awaitsendEmailReply(email.threadId, email.from, email.subject, response);
markEmailResponded(email.id);
logger.info({ to: email.from }, 'Email reply sent');
}
}
} catch (err) {
logger.error({ err }, 'Error in email loop');
}
awaitnewPromise(resolve =>setTimeout(resolve, EMAIL_CHANNEL.pollIntervalMs));
}
}
Then find the `connectWhatsApp`function and add `startEmailLoop()` call after `startMessageLoop()`:
```typescript
// In the connection === 'open' block, after startMessageLoop():
startEmailLoop();
Step 6: Implement Email Agent Runner
Add this function to src/index.ts (or create a separate src/email-agent.ts if preferred):
asyncfunctionrunEmailAgent(contextKey: string,
prompt: string,
email: EmailMessage): Promise<string | null> {
// Email uses either:// 1. A dedicated "email" group folder// 2. Or dynamic folders per thread/senderconst groupFolder = EMAIL_CHANNEL.contextMode === 'single'
? 'main'// Use main group context
: `email/${contextKey}`; // Isolated email context// Ensure folder existsconst groupDir = path.join(GROUPS_DIR, groupFolder);
fs.mkdirSync(groupDir, { recursive: true });
// Create minimal registered group for emailconstemailGroup: RegisteredGroup = {
name: contextKey,
folder: groupFolder,
trigger: '', // No trigger for emailadded_at: newDate().toISOString()
};
// Use existing runAgentconst output = awaitrunAgent(emailGroup, {
prompt,
sessionId: sessions[groupFolder],
groupFolder,
chatJid: `email:${email.from}`, // Use email: prefix for JIDisMain: false,
isScheduledTask: false
});
if (output.newSessionId) {
sessions[groupFolder] = output.newSessionId;
saveJson(path.join(DATA_DIR, 'sessions.json'), sessions);
}
return output.status === 'success' ? output.result : null;
}
Step 7: Update IPC for Email Responses (Optional)
If you want the agent to be able to send emails proactively from within a session, read src/ipc-mcp.ts and add this tool:
// Add to the MCP tools
{
name: 'send_email_reply',
description: 'Send an email reply in the current thread',
inputSchema: {
type: 'object',
properties: {
body: { type: 'string', description: 'Email body content' }
},
required: ['body']
}
}
Then add handling in src/index.ts in the processTaskIpc function or create a new IPC handler for email actions.
Step 8: Create Email Group Memory
Create the email group directory and memory file:
mkdir -p groups/email
Write groups/email/CLAUDE.md:
# Email Channel
You are responding to emails. Your responses will be sent as email replies.
## Guidelines- Be professional and clear
- Keep responses concise but complete
- Use proper email formatting (greetings, sign-off)
- If the email requires action you can't take, explain what the user should do
## Context
Each email thread or sender (depending on configuration) has its own conversation history.
Step 9: Rebuild and Test
Compile TypeScript:
cd .. && bun run build
Restart the service:
launchctl kickstart -k gui/$(id -u)/com.gandalf
Verify it started and check for email channel startup message: