# Add Gmail

> Add Gmail integration to BastionClaw. Can be configured as a tool (agent reads/sends emails when triggered 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.

- Skill: `harperaa/add-gmail` (Agent Skill)
- Install (CLI): `npx skillmds@latest add harperaa/add-gmail`
- Raw SKILL.md: https://api.skillmd.com/api/skills/harperaa/add-gmail/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: harperaa (https://skillmd.com/u/harperaa)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/harperaa/add-gmail

---


# Add Gmail Integration

**UX Rule:** Use `AskUserQuestion` for ALL interactions with the user. Never just output questions as text — always use the tool so the user gets structured prompts with selectable options.

This skill adds Gmail capabilities to BastionClaw. It can be configured in two modes:

1. **Tool Mode** - Agent can read/send emails, but only when triggered from WhatsApp
2. **Channel Mode** - Emails can trigger the agent, schedule tasks, and receive email replies

## Initial Questions

AskUserQuestion: How do you want to use Gmail with BastionClaw?
- **Tool Mode** — Agent can read and send emails when you ask it to. Triggered only from WhatsApp. Simpler setup, no email polling.
- **Channel Mode** — Everything in Tool Mode, plus emails can trigger the agent, schedule tasks, and receive email replies. 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:

```bash
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

```bash
mkdir -p ~/.gmail-mcp
```

### 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:
>
> 1. Open https://console.cloud.google.com in your browser
> 2. Create a new project (or select existing) - click the project dropdown at the top

Wait for user confirmation, then continue:

> 3. Now enable the Gmail API:
>    - In the left sidebar, go to **APIs & Services → Library**
>    - Search for "Gmail API"
>    - Click on it, then click **Enable**

Wait for user confirmation, then continue:

> 4. Now create OAuth credentials:
>    - Go to **APIs & Services → Credentials** (in the left sidebar)
>    - Click **+ CREATE CREDENTIALS** at the top
>    - Select **OAuth client ID**
>    - If prompted for consent screen, choose "External", fill in app name (e.g., "BastionClaw"), your email, and save
>    - For Application type, select **Desktop app**
>    - Name it anything (e.g., "BastionClaw Gmail")
>    - Click **Create**

Wait for user confirmation, then continue:

> 5. Download the credentials:
>    - Click **DOWNLOAD JSON** on the popup (or find it in the credentials list and click the download icon)
>    - Save it as `gcp-oauth.keys.json`
>
> Where did you save the file? (Give me the full path, or just paste the file contents here)

If user provides a path, copy it:

```bash
cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json
```

If user pastes the JSON content, write it directly:

```bash
cat > ~/.gmail-mcp/gcp-oauth.keys.json << 'EOF'
{paste the JSON here}
EOF
```

Verify the file is valid JSON:

```bash
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:

```bash
npx -y @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:

```bash
timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true
```

Tell user:
> 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:

```bash
if [ -f ~/.gmail-mcp/credentials.json ]; then
  echo "Gmail authorization successful!"
  ls -la ~/.gmail-mcp/
else
  echo "ERROR: credentials.json not found - authorization may have failed"
fi
```

Test the connection by listing labels (quick sanity check):

```bash
echo '{"method": "tools/list"}' | timeout 10 npx -y @gongrzhe/server-gmail-autoauth-mcp 2>/dev/null | head -20 || echo "MCP responded (check output above)"
```

If everything works, proceed to implementation.

---

## Tool Mode Implementation

For Tool Mode, integrate Gmail MCP into the agent runner. Execute these changes directly.

### Step 1: Add Gmail MCP to Agent Runner

Read `container/agent-runner/src/index.ts` and find the `mcpServers` config in the `query()` call.

Add `gmail` to the `mcpServers` object:

```typescript
gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] }
```

Find the `allowedTools` array and add Gmail tools:

```typescript
'mcp__gmail__*'
```

The result should look like:

```typescript
mcpServers: {
  bastionclaw: ipcMcp,
  gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] }
},
allowedTools: [
  'Bash',
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
  'WebSearch', 'WebFetch',
  'mcp__bastionclaw__*',
  'mcp__gmail__*'
],
```

### Step 2: Mount Gmail Credentials in Container

Read `src/container-runner.ts` and find the `buildVolumeMounts` function.

Add this mount block (after the `.claude` mount is a good location):

```typescript
// Gmail credentials directory
const gmailDir = path.join(homeDir, '.gmail-mcp');
if (fs.existsSync(gmailDir)) {
  mounts.push({
    hostPath: gmailDir,
    containerPath: '/home/node/.gmail-mcp',
    readonly: false  // MCP may need to refresh tokens
  });
}
```

### Step 3: Update Group Memory

Append to `groups/CLAUDE.md` (the global memory file):

```markdown

## 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 4: Rebuild and Restart

Run these commands:

```bash
cd container && ./build.sh
```

Wait for container build to complete, then:

```bash
cd .. && npm run build
```

Wait for TypeScript compilation, then restart the service:

```bash
launchctl kickstart -k gui/$(id -u)/com.bastionclaw
```

Check that it started:

```bash
sleep 2 && launchctl list | grep bastionclaw
```

### Step 5: Test Gmail Integration

Tell the user:

> Gmail integration is set up! Test it by sending this message in your WhatsApp main channel:
>
> `@Andy check my recent emails`
>
> Or:
>
> `@Andy list my Gmail labels`

Watch the logs for any errors:

```bash
tail -f logs/bastionclaw.log
```

---

## Channel Mode Implementation

Channel Mode includes everything from Tool Mode, plus email polling and routing.

### Additional Questions for Channel Mode

AskUserQuestion: How should the agent be triggered from email?
- **Specific Label** — Create a Gmail label (e.g., "BastionClaw"). Emails with this label trigger the agent. You manually label emails or set up Gmail filters.
- **Email Address Pattern** — Emails to a specific address pattern (e.g., andy+task@gmail.com). Uses Gmail's plus-addressing feature.
- **Subject Prefix** — Emails with a subject starting with a keyword (e.g., "[Andy]"). Anyone can trigger the agent by using the prefix.

AskUserQuestion: How should email conversations be grouped?
- **Per Email Thread** — Each email thread gets its own conversation context. Agent remembers the thread history.
- **Per Sender** — All emails from the same sender share context. Agent remembers all interactions with that person.
- **Single Context** — All emails share the main group context. Like an additional input to the main channel.

Store their choices for implementation.

### Step 1: Complete Tool Mode First

Complete all Tool Mode steps above before continuing. Verify Gmail tools work by having the user test `@Andy check my recent emails`.

### Step 2: Add Email Polling Configuration

Read `src/types.ts` and add this interface:

```typescript
export interface EmailChannelConfig {
  enabled: boolean;
  triggerMode: 'label' | 'address' | 'subject';
  triggerValue: string;  // Label name, address pattern, or subject prefix
  contextMode: 'thread' | 'sender' | 'single';
  pollIntervalMs: number;
  replyPrefix?: string;  // Optional prefix for replies
}
```

Read `src/config.ts` and add this configuration (customize values based on user's earlier answers):

```typescript
export const EMAIL_CHANNEL: EmailChannelConfig = {
  enabled: true,
  triggerMode: 'label',  // or 'address' or 'subject'
  triggerValue: 'BastionClaw',  // the label name, address pattern, or prefix
  contextMode: 'thread',
  pollIntervalMs: 60000,  // Check every minute
  replyPrefix: '[Andy] '
};
```

### Step 3: Add Email State Tracking

Read `src/db.ts` and add these functions for tracking processed emails:

```typescript
// Track processed emails to avoid duplicates
export function initEmailTable(): 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
    )
  `);
}

export function isEmailProcessed(messageId: string): boolean {
  const row = db.prepare('SELECT 1 FROM processed_emails WHERE message_id = ?').get(messageId);
  return !!row;
}

export function markEmailProcessed(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, new Date().toISOString());
}

export function markEmailResponded(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:

```typescript
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 } }
});

interface EmailMessage {
  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 tools

export async function checkForNewEmails(): Promise<EmailMessage[]> {
  // Build query based on trigger mode
  let query: 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 npx gmail MCP as subprocess and parse output
  // Option 3: Import gmail-autoauth-mcp directly

  // Placeholder - implement based on preference
  return [];
}

export async function sendEmailReply(
  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 configured
  const replySubject = subject.startsWith('Re:')
    ? subject
    : `Re: ${subject}`;

  const prefixedBody = EMAIL_CHANNEL.replyPrefix
    ? `${EMAIL_CHANNEL.replyPrefix}${body}`
    : body;

  // Implementation: invoke Gmail MCP send_email
}

export function getContextKey(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:

```typescript
import { checkForNewEmails, sendEmailReply, getContextKey } from './email-channel.js';
import { EMAIL_CHANNEL } from './config.js';
import { isEmailProcessed, markEmailProcessed, markEmailResponded } from './db.js';
```

Then add the `startEmailLoop` function:

```typescript
async function startEmailLoop(): 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 = await checkForNewEmails();

      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 use
        const contextKey = getContextKey(email);

        // Build prompt with email content
        const 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 handler
        const response = await runEmailAgent(contextKey, prompt, email);

        if (response) {
          await sendEmailReply(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');
    }

    await new Promise(resolve => setTimeout(resolve, EMAIL_CHANNEL.pollIntervalMs));
  }
}
```

Then add `startEmailLoop()` in the `main()` function, after `startMessageLoop()`:

```typescript
// In main(), 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):

```typescript
async function runEmailAgent(
  contextKey: string,
  prompt: string,
  email: EmailMessage
): Promise<string | null> {
  // Email uses either:
  // 1. A dedicated "email" group folder
  // 2. Or dynamic folders per thread/sender

  const groupFolder = EMAIL_CHANNEL.contextMode === 'single'
    ? 'main'  // Use main group context
    : `email/${contextKey}`;  // Isolated email context

  // Ensure folder exists
  const groupDir = path.join(GROUPS_DIR, groupFolder);
  fs.mkdirSync(groupDir, { recursive: true });

  // Create minimal registered group for email
  const emailGroup: RegisteredGroup = {
    name: contextKey,
    folder: groupFolder,
    trigger: '',  // No trigger for email
    added_at: new Date().toISOString()
  };

  // Use existing runContainerAgent
  const output = await runContainerAgent(emailGroup, {
    prompt,
    sessionId: sessions[groupFolder],
    groupFolder,
    chatJid: `email:${email.from}`,  // Use email: prefix for JID
    isMain: false,
    isScheduledTask: false
  });

  if (output.newSessionId) {
    sessions[groupFolder] = output.newSessionId;
    setSession(groupFolder, output.newSessionId);
  }

  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 `container/agent-runner/src/ipc-mcp.ts` and add this tool:

```typescript
// 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/ipc.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:

```bash
mkdir -p groups/email
```

Write `groups/email/CLAUDE.md`:

```markdown
# 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

Rebuild the container (required since agent-runner changed):

```bash
cd container && ./build.sh
```

Wait for build to complete, then compile TypeScript:

```bash
cd .. && npm run build
```

Restart the service:

```bash
launchctl kickstart -k gui/$(id -u)/com.bastionclaw
```

Verify it started and check for email channel startup message:

```bash
sleep 3 && tail -20 logs/bastionclaw.log | grep -i email
```

Tell the user:

> Email channel is now active! Test it by sending an email that matches your trigger:
> - **Label mode:** Apply the "${triggerValue}" label to any email
> - **Address mode:** Send an email to ${triggerValue}
> - **Subject mode:** Send an email with subject starting with "${triggerValue}"
>
> The agent should process it within a minute and send a reply.

Monitor for the test:

```bash
tail -f logs/bastionclaw.log | grep -E "(email|Email)"
```

---

## Troubleshooting

### Gmail MCP not responding
```bash
# Test Gmail MCP directly
npx -y @gongrzhe/server-gmail-autoauth-mcp
```

### OAuth token expired
```bash
# Re-authorize
rm ~/.gmail-mcp/credentials.json
npx -y @gongrzhe/server-gmail-autoauth-mcp
```

### Emails not being detected
- Check the trigger configuration matches your test email
- Verify the label exists (for label mode)
- Check `processed_emails` table for already-processed emails

### Container can't access Gmail
- Verify `~/.gmail-mcp` is mounted in container
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`

---

## Removing Gmail Integration

To remove Gmail entirely:

1. Remove from `container/agent-runner/src/index.ts`:
   - Delete `gmail` from `mcpServers`
   - Remove `mcp__gmail__*` from `allowedTools`

2. Remove from `src/container-runner.ts`:
   - Delete the `~/.gmail-mcp` mount block

3. Remove from `src/index.ts` (Channel Mode only):
   - Delete `startEmailLoop()` call
   - Delete email-related imports

4. Delete `src/email-channel.ts` (if created)

5. Remove Gmail sections from `groups/*/CLAUDE.md`

6. Rebuild:
   ```bash
   cd container && ./build.sh && cd ..
   npm run build
   launchctl kickstart -k gui/$(id -u)/com.bastionclaw
   ```

