Build a Gmail AI Agent to Triage Inbox & Draft Replies with Free LLMs
What We're Building
A Gmail AI agent that runs on a cron schedule, processes your unread inbox, and does three things:
- Priority Triage – Classifies each email as
URGENT,NEEDS_REPLY,NEWSLETTER, orLOW_PRIORITYand applies corresponding Gmail labels. - Smart Labeling – Adds contextual labels like
Work,Finance,Travelso your inbox is organized without manual rules. - 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 Project – console.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 key – aistudio.google.com/apikey (Gemini 2.0 Flash is free)
- Vercel account – vercel.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:
- Go to APIs & Services > Credentials
- Click Create Credentials > OAuth client ID
- Choose Web application
- Add
https://developers.google.com/oauthplaygroundas an authorized redirect URI (we'll use this to get a refresh token) - Download the JSON and save the
client_idandclient_secret
Next, get a refresh token using the OAuth 2.0 Playground:
- Click the gear icon and check Use your own OAuth credentials – paste your client ID and secret
- Select
https://mail.google.com/scope - Click Authorize APIs, then Exchange authorization code for tokens
- 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:
- Go to vercel.com/new
- Import your repository
- Add all environment variables from
.env.local - 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']toapplyLabelif you want to auto-mark read)
Sensible Extensions
-
Auto-archive newsletters – If priority is
NEWSLETTER, skip the inbox by addingremoveLabelIds: ['INBOX']to the modify call. See our guide on reclaiming inbox zero with AI. -
Sentiment-aware replies – Extend the Gemini prompt to detect sentiment and adjust tone dynamically. Combine with our prompt engineering patterns for email agents.
-
Multi-user support – Store refresh tokens per user in Vercel KV (free tier includes 256MB). Check our multi-tenant Gmail agent architecture.
-
Slack notifications for urgent emails – Fire a Slack webhook when
priority === 'URGENT'. See building real-time alert pipelines with free tools. -
Calendar integration – Auto-create calendar events from emails mentioning dates. The free-tier Google Calendar API guide covers the setup.
-
Web dashboard – A simple page showing processing stats (emails triaged, drafts created). Vercel Analytics is free and sufficient for this.
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Refresh token expires | Google revokes refresh tokens after 6 months of inactivity. Run the agent at least weekly, or set up token refresh logic. |
| Gemini rate limiting | Free tier is 15 RPM. With 20 emails every 5 minutes, you're fine. Add exponential backoff if you scale up. |
| Multipart email body is empty | Some emails nest parts deeply. Recurse through parts array or use full.payload.body.data as fallback. |
| Vercel cron 60s timeout | Hobby plan limits function execution to 60s. If you have 50+ unread emails, either reduce maxResults or upgrade. |
| Labels not appearing | Gmail labels take a few seconds to propagate. The API returns success immediately, but UI may lag. |
| Draft appears as raw base64 | Ensure 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.
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