●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Building a Cross-Product AI Workflow with Gemini API and Apps Script — Gmail, Calendar, and Drive Automation
Build an end-to-end AI automation pipeline using Gemini API and Google Apps Script that triages Gmail, creates Calendar events, and generates prep notes from Drive documents.
If you use Google Workspace daily, your routine probably looks something like this: check email, add meetings to the calendar, and dig through Drive for relevant documents. While plenty of tutorials show how to add Gemini AI to individual tools, very few explain how to integrate these into a single, automated pipeline that runs with minimal human intervention.
Google Apps Script (GAS) and the Gemini API can carry a single end-to-end workflow: AI-powered email triage in Gmail → automatic Calendar event creation → Drive document search and summarization to produce meeting prep notes. All three phases work together as a unified pipeline.
This article is for intermediate-to-advanced developers who are comfortable with Apps Script basics and have called the Gemini API at least once. We'll also cover the production-critical details: trigger design, error handling, and Google API quota management.
Architecture Overview — A Three-Phase Pipeline
The workflow operates across three distinct phases:
Phase 1 — Email Triage (Gmail → Gemini API)
Fetch unread emails from the inbox, send them to Gemini API for analysis, and receive structured data including urgency level, category, and recommended actions. Based on the results, automatically apply labels, archive low-priority messages, and draft replies.
For emails classified as "meeting invitation" or "deadline notification" in Phase 1, extract date/time, attendees, and agenda using Gemini API, then create Calendar events via the Calendar API. The system also checks for scheduling conflicts and proposes alternatives.
Search Drive for documents related to the newly created events, use Gemini API to summarize and organize key points, and output a "Prep Note" as a Google Doc. Optionally, email the note to attendees 30 minutes before the meeting starts.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A parseGeminiJson helper that handles the rare case of structured output wrapped in a code fence
✦An idempotent label design so duplicate calendar events never appear across the 8:00/12:00 triggers
✦A measured 6-9s-per-email ceiling of 35-50 emails per run, with a time-monitored cutoff design
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Generate an API key from Google AI Studio. Since Apps Script calls the REST API directly via UrlFetchApp, no OAuth flow is needed for Gemini itself.
// Store the API key securely in PropertiesServicefunction setApiKey() { PropertiesService.getScriptProperties().setProperty( 'GEMINI_API_KEY', 'YOUR_GEMINI_API_KEY' // ← Replace with your key from Google AI Studio );}function getApiKey() { return PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');}
Declaring OAuth Scopes
Explicitly declare the required scopes in appsscript.json. Keeping scopes minimal streamlines the deployment review process.
Key decisions here: gmail.modify (not just gmail.readonly, since we need to apply labels), drive.readonly (search and read only), and documents (for creating Docs).
Phase 1 — AI-Powered Email Triage with Gemini API
Fetching and Batching Emails
Use GmailApp to fetch unread emails and create batches for Gemini API processing. To stay within rate limits (free tier: 15 RPM), we process a maximum of 10 emails per run.
/** * Fetch unread emails and triage them using Gemini API * @returns {Array<Object>} Array of triage results */function triageEmails() { const threads = GmailApp.search('is:unread -category:promotions -category:social', 0, 10); const results = []; for (const thread of threads) { const messages = thread.getMessages(); const latest = messages[messages.length - 1]; const emailData = { subject: latest.getSubject(), from: latest.getFrom(), body: latest.getPlainBody().substring(0, 2000), // Limit to 2000 chars to save tokens date: latest.getDate().toISOString() }; const triage = callGeminiForTriage(emailData); if (triage) { results.push({ threadId: thread.getId(), messageId: latest.getId(), ...triage, originalEmail: emailData }); applyTriageActions(thread, triage); } // Rate limit: wait 4 seconds between requests (15 RPM = 4s/request) Utilities.sleep(4000); } return results;}
Calling Gemini API with Structured Output
We use response_mime_type: "application/json" to get type-safe structured output from Gemini.
/** * Send an email to Gemini API for triage analysis * @param {Object} email - Email data object * @returns {Object|null} Triage result */function callGeminiForTriage(email) { const apiKey = getApiKey(); const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; const prompt = `You are an expert email triage assistant. Analyze the following email and return a structured JSON result.Email details:- Subject: ${email.subject}- From: ${email.from}- Date: ${email.date}- Body: ${email.body}Return JSON matching this schema:{ "urgency": "high" | "medium" | "low", "category": "meeting" | "deadline" | "request" | "info" | "spam", "action": "reply_needed" | "calendar_entry" | "archive" | "follow_up", "summary": "1-2 sentence summary", "suggested_reply": "Draft reply if needed, otherwise null", "calendar_info": { "needed": true/false, "title": "Event title", "datetime": "ISO 8601 datetime", "duration_minutes": 60, "attendees": ["email1@example.com"] }}`; const payload = { contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 0.1 // Keep temperature low for classification tasks } }; try { const response = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }); const status = response.getResponseCode(); if (status !== 200) { console.error(`Gemini API error: ${status} - ${response.getContentText()}`); return null; } const json = JSON.parse(response.getContentText()); const text = json.candidates[0].content.parts[0].text; return JSON.parse(text); } catch (e) { console.error(`Triage failed: ${e.message}`); return null; }}
Executing Triage Actions
/** * Apply labels, archive, and create draft replies based on triage results */function applyTriageActions(thread, triage) { // Get or create urgency label const labelName = `AI-Triage/${triage.urgency}`; let label = GmailApp.getUserLabelByName(labelName); if (!label) { label = GmailApp.createLabel(labelName); } thread.addLabel(label); // Apply category label const catLabel = GmailApp.getUserLabelByName(`AI-Category/${triage.category}`) || GmailApp.createLabel(`AI-Category/${triage.category}`); thread.addLabel(catLabel); // Auto-archive low-priority informational emails if (triage.urgency === 'low' && triage.category === 'info') { thread.moveToArchive(); thread.markRead(); } // Create draft reply when needed if (triage.suggested_reply && triage.action === 'reply_needed') { const messages = thread.getMessages(); const latest = messages[messages.length - 1]; latest.createDraftReply(triage.suggested_reply); }}
Phase 2 — Automatic Calendar Event Creation
For emails where calendar_info.needed === true, we create Calendar events with conflict detection.
Checking for Schedule Conflicts
/** * Check if the proposed time slot is available, suggest alternatives if not * @param {string} dateTimeStr - ISO 8601 datetime string * @param {number} durationMin - Duration in minutes * @returns {Object} { available: boolean, suggestedTime: Date } */function checkCalendarAvailability(dateTimeStr, durationMin) { const startTime = new Date(dateTimeStr); const endTime = new Date(startTime.getTime() + durationMin * 60 * 1000); const calendar = CalendarApp.getDefaultCalendar(); const events = calendar.getEvents(startTime, endTime); if (events.length === 0) { return { available: true, suggestedTime: startTime }; } // Conflict detected: search for free slots within business hours const dayStart = new Date(startTime); dayStart.setHours(9, 0, 0, 0); const dayEnd = new Date(startTime); dayEnd.setHours(18, 0, 0, 0); const allEvents = calendar.getEvents(dayStart, dayEnd); const busySlots = allEvents.map(e => ({ start: e.getStartTime().getTime(), end: e.getEndTime().getTime() })).sort((a, b) => a.start - b.start); // Find first available slot let candidateStart = dayStart.getTime(); for (const slot of busySlots) { if (slot.start - candidateStart >= durationMin * 60 * 1000) { return { available: false, suggestedTime: new Date(candidateStart) }; } candidateStart = Math.max(candidateStart, slot.end); } // No availability today — suggest next business day at 9 AM const nextDay = new Date(startTime); nextDay.setDate(nextDay.getDate() + 1); if (nextDay.getDay() === 0) nextDay.setDate(nextDay.getDate() + 1); if (nextDay.getDay() === 6) nextDay.setDate(nextDay.getDate() + 2); nextDay.setHours(9, 0, 0, 0); return { available: false, suggestedTime: nextDay };}
Creating Calendar Events
/** * Create a Calendar event from triage results * @param {Object} triageResult - Phase 1 triage result * @returns {Object|null} Created event information */function createCalendarEvent(triageResult) { const calInfo = triageResult.calendar_info; if (!calInfo || !calInfo.needed) return null; const duration = calInfo.duration_minutes || 60; const availability = checkCalendarAvailability(calInfo.datetime, duration); const eventStart = availability.suggestedTime; const eventEnd = new Date(eventStart.getTime() + duration * 60 * 1000); const calendar = CalendarApp.getDefaultCalendar(); const event = calendar.createEvent( calInfo.title || triageResult.summary, eventStart, eventEnd, { description: `[AI Auto-Created]\n\nEmail summary: ${triageResult.summary}\n\nFrom: ${triageResult.originalEmail.from}`, guests: (calInfo.attendees || []).join(',') } ); // Add note if the time was rescheduled if (!availability.available) { event.setDescription( event.getDescription() + `\n\n⚠️ Rescheduled from original time: ${calInfo.datetime}` ); } // Set 30-minute reminder event.addPopupReminder(30); return { eventId: event.getId(), title: event.getTitle(), start: eventStart.toISOString(), end: eventEnd.toISOString(), rescheduled: !availability.available };}
Phase 3 — Automated Meeting Prep Notes from Drive
Searching for Related Documents
Using the event title and sender information, we search Google Drive for relevant documents.
/** * Search Drive for documents related to a calendar event * @param {Object} eventInfo - Event from Phase 2 * @param {Object} triageResult - Triage from Phase 1 * @returns {Array<Object>} List of related documents */function searchRelatedDocs(eventInfo, triageResult) { const searchQuery = generateSearchQuery(eventInfo, triageResult); const files = DriveApp.searchFiles( `fullText contains '${searchQuery}' and mimeType != 'application/vnd.google-apps.folder' and trashed = false` ); const results = []; let count = 0; while (files.hasNext() && count < 5) { const file = files.next(); results.push({ id: file.getId(), name: file.getName(), mimeType: file.getMimeType(), url: file.getUrl(), lastUpdated: file.getLastUpdated().toISOString() }); count++; } return results;}/** * Use Gemini API to generate optimal search keywords */function generateSearchQuery(eventInfo, triageResult) { const apiKey = getApiKey(); const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; const prompt = `Generate a single search phrase (maximum 5 words) to find relevant documents in Google Drive for the following meeting. Return only the phrase, nothing else.Meeting title: ${eventInfo.title}Email summary: ${triageResult.summary}From: ${triageResult.originalEmail.from}`; const payload = { contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.2, maxOutputTokens: 50 } }; const response = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }); const json = JSON.parse(response.getContentText()); return json.candidates[0].content.parts[0].text.trim();}
Summarizing Documents and Generating Prep Notes
/** * Summarize related documents and create a Google Doc prep note * @param {Object} eventInfo - Event information * @param {Array<Object>} relatedDocs - Related document list * @param {Object} triageResult - Triage result * @returns {string} URL of the created Doc */function createPrepNote(eventInfo, relatedDocs, triageResult) { // Extract content from each document (text-based files only) const docContents = relatedDocs.map(doc => { try { if (doc.mimeType === 'application/vnd.google-apps.document') { const content = DocumentApp.openById(doc.id).getBody().getText(); return { name: doc.name, content: content.substring(0, 3000) }; } if (doc.mimeType === 'application/pdf' || doc.mimeType.startsWith('text/')) { const blob = DriveApp.getFileById(doc.id).getBlob(); return { name: doc.name, content: blob.getDataAsString().substring(0, 3000) }; } return { name: doc.name, content: '(Binary file — content extraction unavailable)' }; } catch (e) { return { name: doc.name, content: `(Read error: ${e.message})` }; } }); // Generate prep note content via Gemini API const noteContent = callGeminiForPrepNote(eventInfo, docContents, triageResult); // Create Google Doc const doc = DocumentApp.create(`📋 Meeting Prep: ${eventInfo.title}`); const body = doc.getBody(); body.appendParagraph(`Meeting Prep: ${eventInfo.title}`) .setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(`Date: ${new Date(eventInfo.start).toLocaleString('en-US')}`); body.appendParagraph(`Generated: ${new Date().toLocaleString('en-US')} (AI-generated)`); body.appendHorizontalRule(); body.appendParagraph(noteContent); // Add links to source documents body.appendParagraph('Reference Documents').setHeading(DocumentApp.ParagraphHeading.HEADING2); for (const d of relatedDocs) { body.appendListItem(d.name).setLinkUrl(d.url); } doc.saveAndClose(); return doc.getUrl();}
/** * Generate prep note content using Gemini API */function callGeminiForPrepNote(eventInfo, docContents, triageResult) { const apiKey = getApiKey(); const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=${apiKey}`; const docsSection = docContents.map(d => `【${d.name}】\n${d.content}` ).join('\n\n---\n\n'); const prompt = `You are a skilled executive assistant. Based on the meeting details and related documents below, create a concise meeting prep note that helps attendees prepare effectively.## Meeting Information- Title: ${eventInfo.title}- Date: ${eventInfo.start}- Context: ${triageResult.summary}## Related Documents${docsSection}## Output Format1. **Meeting Purpose & Context** (2-3 sentences)2. **Key Discussion Points** (3-5 bullet items)3. **Document Highlights** (2-3 sentences per document)4. **Decisions & Action Items Needed** (bullet list)5. **Materials to Prepare in Advance** (if any)Keep it concise and actionable.`; const payload = { contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.3, maxOutputTokens: 2000 } }; const response = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }); const json = JSON.parse(response.getContentText()); return json.candidates[0].content.parts[0].text;}
Pipeline Integration — The Main Orchestrator
Here's the main function that ties all three phases together:
/** * Initial setup: register triggers * Run this once manually from the Apps Script editor */function setupTriggers() { // Remove existing triggers to prevent duplicates const triggers = ScriptApp.getProjectTriggers(); for (const trigger of triggers) { if (trigger.getHandlerFunction() === 'runWorkflowPipeline') { ScriptApp.deleteTrigger(trigger); } } // Run at 8 AM daily ScriptApp.newTrigger('runWorkflowPipeline') .timeBased() .everyDays(1) .atHour(8) .nearMinute(0) .create(); // Run again at noon to catch morning emails ScriptApp.newTrigger('runWorkflowPipeline') .timeBased() .everyDays(1) .atHour(12) .nearMinute(0) .create(); console.log('Triggers configured (daily at 8:00 and 12:00)');}
Handling Execution Time Limits
Apps Script has a 6-minute limit (free accounts) or 30-minute limit (Workspace accounts). For large email volumes, monitor execution time and exit gracefully:
const MAX_EXECUTION_MS = 5 * 60 * 1000; // 5 minutes (with safety margin)function isTimeUp(startTime) { return (new Date() - startTime) > MAX_EXECUTION_MS;}/** * Time-aware email triage (improved version) */function triageEmailsWithTimeLimit() { const startTime = new Date(); const threads = GmailApp.search('is:unread -category:promotions -category:social', 0, 20); const results = []; let processedCount = 0; for (const thread of threads) { if (isTimeUp(startTime)) { console.log(`Time limit reached: ${processedCount}/${threads.length} processed`); // Unprocessed emails remain unread and will be picked up next run break; } const messages = thread.getMessages(); const latest = messages[messages.length - 1]; const emailData = { subject: latest.getSubject(), from: latest.getFrom(), body: latest.getPlainBody().substring(0, 2000), date: latest.getDate().toISOString() }; const triage = callGeminiForTriage(emailData); if (triage) { results.push({ threadId: thread.getId(), ...triage, originalEmail: emailData }); applyTriageActions(thread, triage); } processedCount++; Utilities.sleep(4000); } return results;}
Error Handling and Retry Strategy
In production, you'll encounter rate limits, transient network failures, and token limit errors. Here's a robust retry mechanism:
/** * Retry wrapper with exponential backoff * @param {Function} fn - Function to execute * @param {number} maxRetries - Maximum retry count * @returns {any} Function return value */function withRetry(fn, maxRetries = 3) { let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return fn(); } catch (e) { lastError = e; console.warn(`Attempt ${attempt + 1}/${maxRetries + 1} failed: ${e.message}`); if (attempt < maxRetries) { const backoff = Math.pow(2, attempt) * 1000 + Math.random() * 1000; console.log(`Retrying in ${Math.round(backoff / 1000)}s...`); Utilities.sleep(backoff); } } } throw new Error(`All ${maxRetries + 1} attempts failed: ${lastError.message}`);}// Usage examplefunction callGeminiWithRetry(email) { return withRetry(() => callGeminiForTriage(email), 3);}
Quota Management Dashboard
/** * Track daily API usage via PropertiesService */function trackApiUsage() { const props = PropertiesService.getScriptProperties(); const today = new Date().toISOString().split('T')[0]; const key = `api_usage_${today}`; const current = parseInt(props.getProperty(key) || '0'); props.setProperty(key, String(current + 1)); // Warn when approaching daily limit (free tier: 1500 RPD) if (current + 1 >= 1400) { console.warn(`⚠️ API usage warning: ${current + 1}/1500 RPD`); } return current + 1;}
Security and Privacy Considerations
Sending business emails to an external API requires careful security considerations.
API Key Management: Store keys in PropertiesService, never hardcode them. For team deployments, consider integrating with GCP Secret Manager.
Data Minimization: Limit email body text to 2,000 characters and never send attachments. Add filters to exclude confidential emails from specific senders or with certain keywords.
Log Hygiene: Ensure Stackdriver logs capture only summaries, not full email content.
What the docs don't tell you — lessons from running this in production
I've been building "small automations for myself" since I started developing independently back in 2014, and Workspace integrations via Apps Script are no exception. After running this pipeline for a few weeks, I hit several pitfalls that you simply won't find in the documentation. Here are the four that proved most reproducible and that bite you on day one.
1. Structured output sometimes comes back wrapped in a code fence
Even with responseMimeType: 'application/json' set, gemini-2.5-flash will occasionally return JSON wrapped in a ```json ... ``` fence. In my environment this happened roughly once every 200 requests. JSON.parse() chokes on it, so stripping the fence before parsing eliminates a whole class of silent failures.
Just replace return JSON.parse(text); in callGeminiForTriage with return parseGeminiJson(text);. It's only a few lines, but before adding it I was losing one email's worth of triage to a parse failure once or twice a week.
The first thing that burned me with this pipeline was duplicate Calendar entries. The trigger fires twice (8:00 and 12:00), and unread mail stays in scope on the next run, so the same meeting invite spawns two or three events. The fix is simple: tag triaged threads with a dedicated label and exclude it in the search query.
// Update the search query in triageEmailsconst threads = GmailApp.search( 'is:unread -category:promotions -category:social -label:AI-Triage-Done', 0, 10);// Add to the end of applyTriageActionsconst doneLabel = GmailApp.getUserLabelByName('AI-Triage-Done') || GmailApp.createLabel('AI-Triage-Done');thread.addLabel(doneLabel);
The trick is to not use "read/unread" as your processed flag. Users sometimes mark mail unread manually, so tracking processing state with an explicit label is far safer.
3. You can process fewer emails per run than you'd expect
Measured in practice, per-email processing time settles around 6–9 seconds: Gemini API latency (1–3s) + the Utilities.sleep(4000) rate-limit buffer (4s) + Gmail/Calendar operations (1–2s). Working backwards from the 6-minute limit on free accounts, a realistic safe ceiling is 35–50 emails per run. If your inbox volume is high, time-monitored cutoff (as in triageEmailsWithTimeLimit) that defers the remainder to the next run becomes mandatory. I underestimated this and spent my first week repeatedly failing mid-run with Exceeded maximum execution time.
4. Don't interpolate user-derived strings straight into DriveApp.searchFiles
The Phase 3 search query is generated by Gemini, and if the generated keyword contains an apostrophe ('), the fullText contains '...' query string breaks and the search silently returns zero results. Add a layer of escaping.
function safeFullTextQuery(keyword) { // Escape single quotes to avoid corrupting the Drive query const escaped = keyword.replace(/'/g, "\\'"); return `fullText contains '${escaped}' and mimeType != 'application/vnd.google-apps.folder' and trashed = false`;}
Every official sample uses a hard-coded search term, so this issue never comes up there. In any pipeline that lets a generative model author the search query, you'll almost certainly hit it.
Pre-flight checklist
Before going live, I run through this sequence every time:
Run setApiKey once and confirm via logs that getApiKey() is not empty
Run triageEmails manually and verify on a few emails that labels apply correctly and drafts don't fire off unexpectedly
Confirm the AI-Triage-Done exclusion works and the same email isn't processed twice
Push a single event through createCalendarEvent and eyeball whether the conflict-resolution suggestion is reasonable
Once all of that passes, run setupTriggers and watch the first triggered execution's logs end to end
Run it small, confirm it works, then hand it over to automation. That habit of building trust one careful step at a time feels, to me, quietly connected to the way my grandfathers — both temple carpenters — approached their craft.
Summary
The pipeline above moves from Gmail triage through Calendar event creation to automated meeting prep notes without a hand in the middle. Start by running only the triage phase against your own inbox for a week: you will learn more from the labels it gets wrong than from any amount of upfront design.
The key takeaways for production deployment are trigger design, error handling, and quota management. Proper Utilities.sleep() intervals for rate limiting, exponential backoff retries, and execution time monitoring are essential for reliable operation.
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.