Build a Gmail Triage Agent That Labels, Prioritizes, and Drafts Replies with Gemini
What We're Building
A zero-cost, serverless agent that lives inside Google Workspace. It scans unread emails in your Gmail inbox, classifies them, applies visual labels, flags high-priority threads, and—for quick-hit messages—drafts a context-aware reply so you can hit send after a glance.
Feature list:
- Auto-labeling: Tags emails as
To-Do,Newsletter,Promo,Urgent, orReference. - Priority scoring: Marks high-priority emails with a star and moves them to the top of your inbox.
- Draft generation: Writes a reply draft for straightforward messages using Gemini Flash.
- Fully free: Runs on the Gemini Flash free tier (15 RPM, 1M tokens/day) and Google Apps Script’s free quota (20,000 calls/day).
- Scheduled execution: Triggers every 5–10 minutes so your inbox stays triaged without you touching it.
Architecture Overview
The script polls your inbox, extracts sender/subject/body, and sends a structured prompt to Gemini Flash. The LLM returns a JSON classification and an optional draft reply. The script then applies the corresponding Gmail label, optionally stars the thread, and creates a draft if requested.
Prerequisites
Everything here is free-tier. You need a personal Google account (Gmail) and a few minutes to enable APIs.
| Resource | Purpose | Link |
|---|---|---|
| Google Account | Gmail + Drive for Apps Script | gmail.com |
| Google Cloud Console | Enable Gmail API + get Gemini key | console.cloud.google.com |
| Gemini Flash API | Free LLM for classification/drafting | aistudio.google.com |
Enable the Gmail API:
- Go to the Cloud Console, create a project (or use an existing one).
- Navigate to APIs & Services > Library, search for "Gmail API", and enable it.
- Go to APIs & Services > Credentials, create an API Key. Restrict it to the Gmail API and Gemini API for safety.
Get a Gemini API key:
- Visit Google AI Studio.
- Click Get API Key and create one in the same project where you enabled the Gmail API.
- The free tier gives you 15 requests per minute and 1 million tokens per day—more than enough for personal inbox triage.
Step 1: Setting Up the Google Apps Script Project
Open script.google.com and create a new project. Delete the placeholder myFunction and replace it with the scaffold below.
// Gmail Triage Agent — Gemini Flash + Gmail API
const GEMINI_API_KEY = 'YOUR_GEMINI_API_KEY';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`;
const LABEL_MAP = {
'To-Do': 'To-Do',
'Newsletter': 'Newsletter',
'Promo': 'Promo',
'Urgent': 'Urgent',
'Reference': 'Reference'
};
function main() {
const unreadThreads = GmailApp.search('is:unread -category:social -category:promotions', 0, 10);
if (unreadThreads.length === 0) return;
unreadThreads.forEach(thread => {
processThread(thread);
});
}
Why -category:social -category:promotions? Gmail’s native tab filters are already decent. We skip those to avoid redundant work and stay within the Gemini free tier rate limit. Adjust the query to suit your inbox.
Step 2: Configuring Gmail Labels
Before the script can apply labels, they must exist in your Gmail. Run this once manually from the Apps Script editor:
function createLabels() {
const labels = ['To-Do', 'Newsletter', 'Promo', 'Urgent', 'Reference'];
labels.forEach(name => {
try {
GmailApp.createLabel(name);
Logger.log(`Created label: ${name}`);
} catch (e) {
Logger.log(`Label already exists: ${name}`);
}
});
}
Select createLabels from the dropdown, click Run, and grant the required permissions. You'll see the labels appear in your Gmail sidebar.
Step 3: Writing the Gemini API Client
We send a structured prompt to Gemini Flash and expect a JSON response. The prompt includes the email metadata and a strict output schema.
function classifyAndDraft(email) {
const prompt = `
You are an executive email assistant. Classify the following email and, if appropriate, draft a brief reply.
Email:
- From: ${email.from}
- Subject: ${email.subject}
- Body: ${email.body.substring(0, 1500)}
Respond ONLY with a valid JSON object. No markdown, no backticks.
{
"label": "To-Do" | "Newsletter" | "Promo" | "Urgent" | "Reference",
"priority": "high" | "normal",
"draftReply": "A concise draft reply, or empty string if no reply needed"
}
`;
const payload = {
contents: [{
parts: [{ text: prompt }]
}],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 300
}
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(GEMINI_URL, options);
const json = JSON.parse(response.getContentText());
const rawText = json.candidates[0].content.parts[0].text;
// Strip any accidental markdown fences
const cleanJson = rawText.replace(/```json|```/g, '').trim();
return JSON.parse(cleanJson);
} catch (e) {
Logger.log(`Gemini error: ${e}`);
return { label: 'Reference', priority: 'normal', draftReply: '' };
}
}
Why temperature: 0.2? Classification tasks need consistency. Low temperature reduces hallucinated labels. The muteHttpExceptions: true flag lets us catch rate-limit errors gracefully instead of crashing the script.
Step 4: Implementing the Triage Logic
Now we wire the classification result to Gmail actions.
function processThread(thread) {
const messages = thread.getMessages();
const latest = messages[messages.length - 1];
const email = {
from: latest.getFrom(),
subject: latest.getSubject(),
body: latest.getPlainBody()
};
const result = classifyAndDraft(email);
const labelName = LABEL_MAP[result.label] || 'Reference';
const label = GmailApp.getUserLabelByName(labelName);
if (label) {
thread.addLabel(label);
}
if (result.priority === 'high') {
thread.moveToInbox(); // ensure it stays in Primary
messages.forEach(msg => msg.star());
}
if (result.draftReply && result.draftReply.trim() !== '') {
createDraft(thread, result.draftReply);
}
// Mark as read so we don't process it again
thread.markRead();
}
Important: thread.markRead() at the end prevents duplicate processing. If you prefer to keep emails unread, use a custom label like Processed instead.
Step 5: Drafting Replies via the Gmail API
Apps Script’s built-in GmailApp can create drafts, but we need to quote the original message properly. The snippet below creates a draft reply in the same thread.
function createDraft(thread, replyBody) {
const messages = thread.getMessages();
const latest = messages[messages.length - 1];
const replyTo = latest.getFrom();
const subject = latest.getSubject();
const quotedBody = latest.getPlainBody().split('\n').map(line => `> ${line}`).join('\n');
const fullReply = `${replyBody}\n\n---\n${quotedBody}`;
GmailApp.createDraft(replyTo, `Re: ${subject}`, fullReply, {
threadId: thread.getId()
});
}
Step 6: Creating the Time-Driven Trigger
In the Apps Script editor, click the clock icon on the left (Triggers), then Add Trigger:
- Choose function:
main - Choose deployment:
Head - Select event source:
Time-driven - Select type:
Minutes timer - Minute interval:
Every 5 minutes(or 10 to stay well under quotas)
Click Save, review permissions, and you're live. The script will now run on autopilot.
Running the Agent
- Send yourself a few test emails from different accounts.
- Wait for the trigger to fire, or run
main()manually from the editor. - Check your inbox: labels should appear, high-priority threads starred, and drafts waiting in the thread.
- Open the Executions tab in Apps Script to see logs and catch any errors.
Monitoring tip: Add a summary log at the end of main():
Logger.log(`Processed ${unreadThreads.length} threads.`);
Extensions
Once the core loop works, extend it without leaving the free tier:
- Custom reply templates. Add a
toneparameter to the prompt—"professional", "casual", "terse"—and map it to different sender domains. - Auto-archive newsletters. If label is
Newsletterand priority isnormal, callthread.moveToArchive()immediately. - Spam triage. Route
Promoemails with low sender reputation to trash. UseGmailApp.getUserLabelByName('Promo')as a quarantine. - Webhook notifications. For
Urgentemails, fire a POST to a Discord/Slack webhook usingUrlFetchApp. - Multi-inbox support. Extend
GmailApp.search()within:inboxand loop over multiple email aliases if you have delegated access.
For a deeper dive into automating developer workflows with free LLMs, check out Build a Screenshot-to-Code Agent Using Groq Vision & Vercel in 30 Minutes and Deploy a GitHub PR Review Bot with Gemini Flash Free Tier and GitHub Actions.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Quota exceeded | 429 Too Many Requests from Gemini | Reduce trigger frequency to 10 min; lower maxOutputTokens |
| Labels not applied | Script runs but no labels appear | Run createLabels() once; check label names match exactly |
| Drafts not created | Empty draft or missing in thread | Verify GmailApp.createDraft has threadId in options |
| JSON parse error | SyntaxError in logs | Gemini sometimes wraps JSON in ``` fences; the cleanJson regex handles this |
| Script timeout | Execution exceeds 6 minutes | Process fewer threads per run (reduce the 10 in search()) |
| Permissions not granted | Authorization is required | Re-run from editor, accept the OAuth scope for Gmail |
FAQ
Q: Will this mark all my emails as read?
Yes, by design. The script calls thread.markRead() after processing to avoid re-triage. If you prefer to keep emails unread, create a Processed label and check for it in the search query (-label:Processed).
Q: How do I stop the agent from drafting replies to certain senders?
Add a blocklist array at the top of processThread():
const BLOCKED_SENDERS = ['noreply@example.com', 'notifications@github.com'];
if (BLOCKED_SENDERS.some(s => email.from.includes(s))) {
thread.markRead();
return;
}
Q: Can I use a different LLM?
Absolutely. Swap the GEMINI_URL and payload shape for any API that accepts a prompt and returns text. Groq's free tier works well—see Build a Multi-Agent Research Assistant with LangGraph and Groq Free Tier for a similar pattern.
Q: What's the cost at scale? For a personal inbox receiving 100–200 emails/day, you'll stay well within the Gemini Flash free tier. The Apps Script free quota (20,000 calls/day) is rarely a bottleneck. If you're managing a team inbox with thousands of emails, consider batching and upgrading to a paid Gemini plan.
Q: How do I make the drafts sound more like me? Include a few example replies in the prompt as few-shot examples. The prompt is the product—iterate on it. For more on building AI agents that ship, The FDE Portfolio: 4 Projects to Build to Prove You Can Ship in the Customer's Chaos covers exactly this kind of practical build.
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