All articles
Build Guides

Build a Gmail AI Agent to Triage Inbox & Draft Replies with Free LLMs

FDE Coach EditorialJuly 11, 202610 min read

What We're Building

A Gmail AI agent that runs on a cron schedule, processes your unread inbox, and does three things:

  1. Priority Triage – Classifies each email as URGENT, NEEDS_REPLY, NEWSLETTER, or LOW_PRIORITY and applies corresponding Gmail labels.
  2. Smart Labeling – Adds contextual labels like Work, Finance, Travel so your inbox is organized without manual rules.
  3. One-Click Draft Replies – For emails requiring a response, the agent drafts a contextual reply and saves it as a Gmail draft. You review and hit send.

All of this runs on Google Gemini’s free tier (15 requests/minute, 1,500 requests/day) and Vercel’s free hobby plan. No credit card. No OpenAI bill.

Architecture Overview

The entire agent is a single Next.js API route. Vercel cron pings it every 5 minutes. The handler fetches unread emails via the Gmail API, sends each to Gemini for classification and optional draft generation, then writes labels and drafts back to Gmail. State is managed entirely through Gmail labels—no database required.

Prerequisites (All Free Tier)

  • Google Cloud Projectconsole.cloud.google.com (free tier includes $300 credit for 90 days, but we won't exceed free quotas)
  • Gmail API enabled – Free within quota (1,000,000 units/day)
  • Gemini API keyaistudio.google.com/apikey (Gemini 2.0 Flash is free)
  • Vercel accountvercel.com (hobby plan, no credit card)
  • Node.js 20+ and a GitHub account

Step 1: Google Cloud & Gmail API Setup

Head to Google Cloud Console and create a new project. Enable the Gmail API from the API Library.

Now create an OAuth 2.0 client ID:

  1. Go to APIs & Services > Credentials
  2. Click Create Credentials > OAuth client ID
  3. Choose Web application
  4. Add https://developers.google.com/oauthplayground as an authorized redirect URI (we'll use this to get a refresh token)
  5. Download the JSON and save the client_id and client_secret

Next, get a refresh token using the OAuth 2.0 Playground:

  1. Click the gear icon and check Use your own OAuth credentials – paste your client ID and secret
  2. Select https://mail.google.com/ scope
  3. Click Authorize APIs, then Exchange authorization code for tokens
  4. Save the refresh token

You now have three secrets: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN. These let the agent act on your behalf indefinitely without re-auth.

Step 2: Gemini API Key

Visit Google AI Studio and click Get API Key. Choose a new key from a free-tier project. Copy the key—it starts with AIza.

Free tier limits: 15 RPM, 1,500 RPD, 1M tokens/minute. More than enough for personal inbox triage.

Step 3: Project Initialization

Scaffold a Next.js project:

npx create-next-app@latest gmail-ai-agent --typescript --tailwind --app
cd gmail-ai-agent
npm install googleapis google-auth-library @google/generative-ai

Create a .env.local file:

GMAIL_CLIENT_ID=your_client_id
GMAIL_CLIENT_SECRET=your_client_secret
GMAIL_REFRESH_TOKEN=your_refresh_token
GEMINI_API_KEY=your_gemini_key
CRON_SECRET=generate_a_random_string_here

The CRON_SECRET protects your endpoint from public calls—Vercel cron passes it as an Authorization header.

Step 4: Core Gmail Service

Create lib/gmail.ts:

import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';

const oauth2Client = new OAuth2Client(
  process.env.GMAIL_CLIENT_ID,
  process.env.GMAIL_CLIENT_SECRET
);
oauth2Client.setCredentials({
  refresh_token: process.env.GMAIL_REFRESH_TOKEN,
});

export const gmail = google.gmail({ version: 'v1', auth: oauth2Client });

export async function listUnreadMessages(maxResults = 20) {
  const res = await gmail.users.messages.list({
    userId: 'me',
    q: 'is:unread',
    maxResults,
  });
  return res.data.messages || [];
}

export async function getMessage(messageId: string) {
  const res = await gmail.users.messages.get({
    userId: 'me',
    id: messageId,
    format: 'full',
  });
  return res.data;
}

export function decodeBase64(data: string) {
  return Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8');
}

export async function applyLabel(messageId: string, labelName: string) {
  const labelId = await getOrCreateLabel(labelName);
  await gmail.users.messages.modify({
    userId: 'me',
    id: messageId,
    requestBody: { addLabelIds: [labelId] },
  });
}

export async function createDraft(
  threadId: string,
  to: string,
  subject: string,
  body: string
) {
  const raw = Buffer.from(
    `To: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n${body}`
  ).toString('base64url');

  await gmail.users.drafts.create({
    userId: 'me',
    requestBody: {
      message: {
        threadId,
        raw,
      },
    },
  });
}

async function getOrCreateLabel(name: string): Promise<string> {
  const res = await gmail.users.labels.list({ userId: 'me' });
  const existing = res.data.labels?.find((l) => l.name === name);
  if (existing?.id) return existing.id;

  const created = await gmail.users.labels.create({
    userId: 'me',
    requestBody: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' },
  });
  return created.data.id!;
}

Step 5: AI Triage & Labeling Logic

Create lib/gemini.ts:

import { GoogleGenerativeAI } from '@google/generative-ai';

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });

interface TriageResult {
  priority: 'URGENT' | 'NEEDS_REPLY' | 'NEWSLETTER' | 'LOW_PRIORITY';
  categories: string[];
  needsReply: boolean;
  replyContext?: {
    tone: 'formal' | 'casual' | 'grateful' | 'apologetic';
    keyPoints: string[];
    suggestedReply: string;
  };
}

export async function triageEmail(
  subject: string,
  from: string,
  snippet: string,
  body: string
): Promise<TriageResult> {
  const prompt = `You are an inbox triage assistant. Analyze this email and return ONLY valid JSON (no markdown, no backticks) matching this TypeScript interface:

{
  priority: "URGENT" | "NEEDS_REPLY" | "NEWSLETTER" | "LOW_PRIORITY",
  categories: string[], // max 3: e.g. "Work", "Finance", "Travel", "Personal", "Receipt", "Social"
  needsReply: boolean,
  replyContext?: {
    tone: "formal" | "casual" | "grateful" | "apologetic",
    keyPoints: string[], // 2-3 bullet points to address
    suggestedReply: string // full draft reply, 2-4 sentences, ready to send
  }
}

Rules:
- URGENT: requires immediate action, time-sensitive, from boss/client
- NEEDS_REPLY: question or request that warrants a response
- NEWSLETTER: mass email, marketing, subscription
- LOW_PRIORITY: notification, receipt, FYI
- Only include replyContext if needsReply is true
- suggestedReply must be in first person, ready to send, no placeholders

Email:
From: ${from}
Subject: ${subject}
Snippet: ${snippet}
Body: ${body.substring(0, 3000)}`;

  const result = await model.generateContent(prompt);
  const text = result.response.text();

  // Strip any accidental markdown fences
  const cleaned = text.replace(/^```(?:json)?\s*|```\s*$/g, '').trim();
  return JSON.parse(cleaned);
}

Gemini 2.0 Flash is fast (sub-second) and the free tier handles 1,500 requests/day. For a personal inbox with 50-100 emails/day, you'll never hit the limit.

Step 6: Draft Reply Generation

The triageEmail function already returns a suggestedReply when needsReply is true. We just need to plumb it into Gmail's draft system.

Create lib/processor.ts:

import { listUnreadMessages, getMessage, decodeBase64, applyLabel, createDraft } from './gmail';
import { triageEmail } from './gemini';

export async function processInbox() {
  const messages = await listUnreadMessages(20);
  console.log(`Found ${messages.length} unread messages`);

  for (const msg of messages) {
    try {
      const full = await getMessage(msg.id!);
      const headers = full.payload?.headers || [];
      const subject = headers.find((h) => h.name === 'Subject')?.value || '(no subject)';
      const from = headers.find((h) => h.name === 'From')?.value || 'unknown';
      const snippet = full.snippet || '';

      // Decode body (handles multipart)
      let body = '';
      if (full.payload?.body?.data) {
        body = decodeBase64(full.payload.body.data);
      } else if (full.payload?.parts) {
        for (const part of full.payload.parts) {
          if (part.mimeType === 'text/plain' && part.body?.data) {
            body += decodeBase64(part.body.data);
          }
        }
      }

      const triage = await triageEmail(subject, from, snippet, body);

      // Apply priority label
      await applyLabel(msg.id!, triage.priority);

      // Apply category labels
      for (const cat of triage.categories) {
        await applyLabel(msg.id!, cat);
      }

      // Create draft if needed
      if (triage.needsReply && triage.replyContext) {
        const to = from.match(/<(.+?)>/) ? from.match(/<(.+?)>/)![1] : from;
        const replySubject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
        await createDraft(full.threadId!, to, replySubject, triage.replyContext.suggestedReply);
        console.log(`Draft created for: ${subject}`);
      }

      console.log(`Processed: ${subject} → ${triage.priority}`);
    } catch (err) {
      console.error(`Failed to process message ${msg.id}:`, err);
    }
  }
}

Step 7: Webhook Endpoint & Vercel Cron

Create app/api/cron/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { processInbox } from '@/lib/processor';

export const runtime = 'nodejs';
export const maxDuration = 60; // Hobby plan max

export async function GET(req: NextRequest) {
  const authHeader = req.headers.get('authorization');
  const expected = `Bearer ${process.env.CRON_SECRET}`;

  if (authHeader !== expected) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    await processInbox();
    return NextResponse.json({ success: true });
  } catch (err) {
    console.error(err);
    return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
  }
}

Add to vercel.json at project root:

{
  "crons": [
    {
      "path": "/api/cron",
      "schedule": "*/5 * * * *"
    }
  ]
}

This pings your endpoint every 5 minutes. Vercel automatically adds an Authorization: Bearer <CRON_SECRET> header—you don't need to configure it manually.

Step 8: Deploy to Vercel

Push to GitHub, then:

  1. Go to vercel.com/new
  2. Import your repository
  3. Add all environment variables from .env.local
  4. Deploy

Vercel will detect the vercel.json cron configuration automatically. After deployment, check the runtime logs to confirm cron is firing.

Running the Agent

Once deployed, the agent runs itself. You can also trigger it manually:

curl -H "Authorization: Bearer YOUR_CRON_SECRET" https://your-app.vercel.app/api/cron

Check your Gmail—within minutes you'll see:

  • New labels: URGENT, NEEDS_REPLY, NEWSLETTER, LOW_PRIORITY, plus any category labels the AI assigns
  • Drafts appear in your Drafts folder with suggested replies
  • Emails remain unread initially (add removeLabelIds: ['UNREAD'] to applyLabel if you want to auto-mark read)

Sensible Extensions

  1. Auto-archive newsletters – If priority is NEWSLETTER, skip the inbox by adding removeLabelIds: ['INBOX'] to the modify call. See our guide on reclaiming inbox zero with AI.

  2. Sentiment-aware replies – Extend the Gemini prompt to detect sentiment and adjust tone dynamically. Combine with our prompt engineering patterns for email agents.

  3. Multi-user support – Store refresh tokens per user in Vercel KV (free tier includes 256MB). Check our multi-tenant Gmail agent architecture.

  4. Slack notifications for urgent emails – Fire a Slack webhook when priority === 'URGENT'. See building real-time alert pipelines with free tools.

  5. Calendar integration – Auto-create calendar events from emails mentioning dates. The free-tier Google Calendar API guide covers the setup.

  6. Web dashboard – A simple page showing processing stats (emails triaged, drafts created). Vercel Analytics is free and sufficient for this.

Common Pitfalls

PitfallFix
Refresh token expiresGoogle revokes refresh tokens after 6 months of inactivity. Run the agent at least weekly, or set up token refresh logic.
Gemini rate limitingFree tier is 15 RPM. With 20 emails every 5 minutes, you're fine. Add exponential backoff if you scale up.
Multipart email body is emptySome emails nest parts deeply. Recurse through parts array or use full.payload.body.data as fallback.
Vercel cron 60s timeoutHobby plan limits function execution to 60s. If you have 50+ unread emails, either reduce maxResults or upgrade.
Labels not appearingGmail labels take a few seconds to propagate. The API returns success immediately, but UI may lag.
Draft appears as raw base64Ensure you're using base64url encoding (not standard base64) and the Content-Type header is present.

FAQ

Q: Does this read all my emails?
A: Only unread emails, and only when the cron job fires. Nothing is stored externally—processing happens in-memory.

Q: Can I customize the priority rules?
A: Yes. Edit the prompt in gemini.ts. Add rules like "emails from my boss are always URGENT" or "emails containing 'invoice' are Finance."

Q: What if Gemini hallucinates a reply?
A: Drafts are never sent automatically. You review every draft before hitting send. The agent is a co-pilot, not an auto-responder.

Q: How much does this actually cost?
A: $0. Gmail API is free within quota, Gemini 2.0 Flash is free, Vercel Hobby is free. No credit card required anywhere.

Q: Can I run this locally instead?
A: Absolutely. Replace the cron with a setInterval in a Node script or use node-cron. The core logic is platform-agnostic.

Q: What about emails in languages other than English?
A: Gemini 2.0 Flash handles 100+ languages. The prompt is in English, but it'll classify and draft replies in the email's original language.

#email-automation#ai-agents#productivity

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now