All articles
Build Guides

Build a Gmail Triage Agent That Labels, Prioritizes, and Drafts Replies with Gemini

FDE Coach EditorialAugust 24, 20269 min read

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, or Reference.
  • 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.

ResourcePurposeLink
Google AccountGmail + Drive for Apps Scriptgmail.com
Google Cloud ConsoleEnable Gmail API + get Gemini keyconsole.cloud.google.com
Gemini Flash APIFree LLM for classification/draftingaistudio.google.com

Enable the Gmail API:

  1. Go to the Cloud Console, create a project (or use an existing one).
  2. Navigate to APIs & Services > Library, search for "Gmail API", and enable it.
  3. 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:

  1. Visit Google AI Studio.
  2. Click Get API Key and create one in the same project where you enabled the Gmail API.
  3. 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

  1. Send yourself a few test emails from different accounts.
  2. Wait for the trigger to fire, or run main() manually from the editor.
  3. Check your inbox: labels should appear, high-priority threads starred, and drafts waiting in the thread.
  4. 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:

  1. Custom reply templates. Add a tone parameter to the prompt—"professional", "casual", "terse"—and map it to different sender domains.
  2. Auto-archive newsletters. If label is Newsletter and priority is normal, call thread.moveToArchive() immediately.
  3. Spam triage. Route Promo emails with low sender reputation to trash. Use GmailApp.getUserLabelByName('Promo') as a quarantine.
  4. Webhook notifications. For Urgent emails, fire a POST to a Discord/Slack webhook using UrlFetchApp.
  5. Multi-inbox support. Extend GmailApp.search() with in:inbox and 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

PitfallSymptomFix
Quota exceeded429 Too Many Requests from GeminiReduce trigger frequency to 10 min; lower maxOutputTokens
Labels not appliedScript runs but no labels appearRun createLabels() once; check label names match exactly
Drafts not createdEmpty draft or missing in threadVerify GmailApp.createDraft has threadId in options
JSON parse errorSyntaxError in logsGemini sometimes wraps JSON in ``` fences; the cleanJson regex handles this
Script timeoutExecution exceeds 6 minutesProcess fewer threads per run (reduce the 10 in search())
Permissions not grantedAuthorization is requiredRe-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.

#email#gmail#gemini#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