How to Build Internal Google Workspace Add-ons: Complete Walkthrough

Creating robust internal tools is a critical endeavor for any engineering organization aiming to streamline workflows, enhance productivity, and automate repetitive tasks. Google Workspace Add-ons, powered by Google Apps Script, offer a powerful way to build custom integrations directly into Gmail, Calendar, Drive, and other Workspace applications. This comprehensive walkthrough will guide you through the entire process of creating, testing, and deploying an internal Workspace Add-on for your organization.

Unlike standalone web applications, Workspace Add-ons provide a context-aware user experience directly within Google application interfaces, minimizing context switching and improving user adoption. Apps Script, Google’s serverless JavaScript-based development platform, handles the backend without requiring you to manage servers, authentication flows, or complex infrastructure. By the end of this guide, you’ll have built a functional add-on and understand how to deploy it organization-wide.

What You’ll Build

In this walkthrough, we’ll create a Customer Information Add-on for Gmail that automatically fetches customer data when you open an email. This practical example demonstrates key concepts you can apply to any internal tool:

  • Context-aware UI that responds to Gmail events
  • External API integration
  • Professional card-based interface
  • Organization-wide deployment

Prerequisites

Before starting, ensure you have:

  1. Google Workspace Account: You need a Google Workspace (formerly G Suite) account, not a personal Gmail account
  2. Admin Access: To deploy organization-wide, you’ll need Google Workspace Admin privileges (or coordinate with your admin)
  3. JavaScript Knowledge: Familiarity with modern JavaScript (ES6+)
  4. Access to Apps Script: Navigate to script.google.com and verify you can create a new project

Note: For testing during development, admin access isn’t required - you can test add-ons in your own account.

Step 1: Create Your First Apps Script Project

Let’s start by creating a new Apps Script project and setting up the basic structure.

1.1 Initialize the Project

  1. Navigate to script.google.com
  2. Click “New project” in the top left
  3. Rename your project to “Customer Info Add-on” by clicking “Untitled project”
  4. You’ll see a default Code.gs file with a myFunction() template

1.2 Set Up the Manifest

The manifest file (appsscript.json) is the configuration brain of your add-on. It defines metadata, OAuth scopes, and event triggers.

  1. In your Apps Script editor, click the gear icon (⚙️) for “Project Settings”
  2. Check “Show ‘appsscript.json’ manifest file in editor”
  3. Return to the “Editor” tab - you’ll now see appsscript.json in your file list
  4. Replace the contents of appsscript.json with:
{
  "timeZone": "America/New_York",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
    "https://www.googleapis.com/auth/script.external_request"
  ],
  "gmail": {
    "name": "Customer Info",
    "logoUrl": "https://www.gstatic.com/images/branding/product/1x/apps_script_48dp.png",
    "contextualTriggers": [
      {
        "unconditional": {},
        "onTriggerFunction": "onGmailMessageOpen"
      }
    ],
    "homepageTrigger": {
      "runFunction": "onHomepage"
    }
  }
}

Key Configuration Elements:

  • runtimeVersion: "V8": Ensures you’re using the modern V8 JavaScript runtime with ES6+ support
  • oauthScopes: Defines what permissions your add-on needs
    • gmail.addons.current.message.readonly: Read the currently open email
    • script.external_request: Make HTTP requests to external APIs
  • contextualTriggers: Defines when your add-on appears (here, on any opened email)
  • homepageTrigger: Function called when add-on is opened without specific context

Step 2: Build the User Interface with Card Service

Now let’s create the actual add-on logic. Replace the contents of Code.gs with the following code:

/**
 * Homepage shown when add-on is opened without email context
 */
function onHomepage() {
  const card = CardService.newCardBuilder()
    .setHeader(
      CardService.newCardHeader()
        .setTitle('Customer Info Add-on')
        .setSubtitle('Open an email to see customer details')
    )
    .addSection(
      CardService.newCardSection()
        .addWidget(
          CardService.newTextParagraph()
            .setText('This add-on displays customer information when you open an email.')
        )
        .addWidget(
          CardService.newTextParagraph()
            .setText('Open any email to see it in action!')
        )
    )
    .build();
  
  return card;
}

/**
 * Triggered when user opens an email in Gmail
 */
function onGmailMessageOpen(e) {
  // Extract email data from the event object
  const messageId = e.gmail.messageId;
  const accessToken = e.gmail.accessToken;
  
  // Get the message using Gmail API
  const message = getCurrentMessage(messageId, accessToken);
  
  // Extract customer identifier from email
  // For this example, we'll look for customer ID in subject or from address
  const customerId = extractCustomerId(message);
  
  // Fetch customer data (simulated - replace with your actual API)
  const customerData = fetchCustomerData(customerId);
  
  // Build and return the UI card
  return buildCustomerCard(customerData);
}

/**
 * Get current Gmail message details
 */
function getCurrentMessage(messageId, accessToken) {
  const url = `https://www.googleapis.com/gmail/v1/users/me/messages/${messageId}`;
  
  const response = UrlFetchApp.fetch(url, {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  return JSON.parse(response.getContentText());
}

/**
 * Extract customer ID from email
 * In real implementation, you'd have logic to parse the email
 */
function extractCustomerId(message) {
  // Look for customer ID in subject line (format: [CID:12345])
  const headers = message.payload.headers;
  const subject = headers.find(h => h.name === 'Subject')?.value || '';
  
  const cidMatch = subject.match(/\[CID:(\d+)\]/);
  if (cidMatch) {
    return cidMatch[1];
  }
  
  // Fallback: extract from sender email domain
  const from = headers.find(h => h.name === 'From')?.value || '';
  const emailMatch = from.match(/(\w+)@company\.com/);
  if (emailMatch) {
    return emailMatch[1];
  }
  
  return 'unknown';
}

/**
 * Fetch customer data from your internal API
 * This is simulated - replace with actual API call
 */
function fetchCustomerData(customerId) {
  // In production, you'd call your actual CRM API:
  // const response = UrlFetchApp.fetch(`https://your-crm.com/api/customers/${customerId}`, {
  //   headers: { 'Authorization': 'Bearer YOUR_API_TOKEN' }
  // });
  // return JSON.parse(response.getContentText());
  
  // Simulated data for demonstration
  return {
    id: customerId,
    name: 'Acme Corporation',
    status: 'Active',
    tier: 'Enterprise',
    accountManager: 'Jane Smith',
    lastContact: '2025-11-28',
    totalRevenue: '$125,000',
    supportTickets: 3
  };
}

/**
 * Build the UI card displaying customer information
 */
function buildCustomerCard(customer) {
  const card = CardService.newCardBuilder();
  
  // Header
  card.setHeader(
    CardService.newCardHeader()
      .setTitle(`Customer: ${customer.name}`)
      .setSubtitle(`ID: ${customer.id} | ${customer.tier}`)
  );
  
  // Customer Details Section
  const detailsSection = CardService.newCardSection()
    .setHeader('Account Details');
  
  detailsSection.addWidget(
    CardService.newKeyValue()
      .setTopLabel('Status')
      .setContent(customer.status)
      .setIcon(customer.status === 'Active' ? 
        CardService.Icon.CONFIRMATION_NUMBER_ICON : 
        CardService.Icon.DESCRIPTION)
  );
  
  detailsSection.addWidget(
    CardService.newKeyValue()
      .setTopLabel('Account Manager')
      .setContent(customer.accountManager)
      .setIcon(CardService.Icon.PERSON)
  );
  
  detailsSection.addWidget(
    CardService.newKeyValue()
      .setTopLabel('Last Contact')
      .setContent(customer.lastContact)
      .setIcon(CardService.Icon.CLOCK)
  );
  
  card.addSection(detailsSection);
  
  // Metrics Section
  const metricsSection = CardService.newCardSection()
    .setHeader('Key Metrics');
  
  metricsSection.addWidget(
    CardService.newKeyValue()
      .setTopLabel('Total Revenue')
      .setContent(customer.totalRevenue)
      .setIcon(CardService.Icon.DOLLAR)
  );
  
  metricsSection.addWidget(
    CardService.newKeyValue()
      .setTopLabel('Open Support Tickets')
      .setContent(String(customer.supportTickets))
      .setIcon(CardService.Icon.EMAIL)
  );
  
  card.addSection(metricsSection);
  
  // Action Buttons Section
  const actionsSection = CardService.newCardSection();
  
  actionsSection.addWidget(
    CardService.newButtonSet()
      .addButton(
        CardService.newTextButton()
          .setText('View in CRM')
          .setOpenLink(CardService.newOpenLink()
            .setUrl(`https://crm.example.com/customers/${customer.id}`)
            .setOpenAs(CardService.OpenAs.FULL_SIZE)
            .setOnClose(CardService.OnClose.NOTHING))
      )
      .addButton(
        CardService.newTextButton()
          .setText('Create Ticket')
          .setOnClickAction(
            CardService.newAction()
              .setFunctionName('createSupportTicket')
              .setParameters({customerId: customer.id})
          )
      )
  );
  
  card.addSection(actionsSection);
  
  return card.build();
}

/**
 * Handler for "Create Ticket" button
 */
function createSupportTicket(e) {
  const customerId = e.parameters.customerId;
  
  // In production, create ticket via API
  // For demo, show success message
  
  const notification = CardService.newActionResponseBuilder()
    .setNotification(
      CardService.newNotification()
        .setText(`Support ticket created for customer ${customerId}`)
    )
    .build();
  
  return notification;
}

This code creates a complete add-on with:

  • Homepage: Shown when opened without email context
  • Email context trigger: Automatically displays when email is opened
  • Customer data extraction: Parses customer ID from email
  • API integration: Fetches data (simulated, easily replaceable)
  • Professional UI: Uses Card Service widgets for clean presentation
  • Interactive buttons: Actions that link to external systems

Note: The image below is a placeholder. In your organization’s documentation, consider creating an actual architecture diagram showing the flow between Gmail → Apps Script → External APIs → Card UI.

Step 3: Testing Your Add-on

Before deploying to your organization, thoroughly test the add-on in your personal environment.

3.1 Test Deployment

  1. In the Apps Script editor, click DeployTest deployments
  2. Click Install to install the add-on for yourself only
  3. Open Gmail in a new tab
  4. Click on any email
  5. Look for your add-on icon in the right sidebar
  6. Click it to see your add-on in action

Troubleshooting Test Deployments:

  • Add-on not appearing? Wait 1-2 minutes for Gmail to refresh, then reload the page
  • Errors displayed? Check the Apps Script execution logs: ViewExecutions
  • Authorization required? Click “Authorize” and grant necessary permissions

3.2 Debugging Tips

Apps Script provides excellent debugging capabilities:

// Add logging throughout your code
function onGmailMessageOpen(e) {
  console.log('Event data:', JSON.stringify(e));
  
  const messageId = e.gmail.messageId;
  console.log('Processing message:', messageId);
  
  try {
    const message = getCurrentMessage(messageId, e.gmail.accessToken);
    console.log('Message data:', JSON.stringify(message));
    
    const customerId = extractCustomerId(message);
    console.log('Extracted customer ID:', customerId);
    
    const customerData = fetchCustomerData(customerId);
    console.log('Customer data:', JSON.stringify(customerData));
    
    return buildCustomerCard(customerData);
  } catch (error) {
    console.error('Error:', error);
    return buildErrorCard(error.message);
  }
}

/**
 * Display error in user-friendly card
 */
function buildErrorCard(errorMessage) {
  return CardService.newCardBuilder()
    .setHeader(
      CardService.newCardHeader()
        .setTitle('Error')
    )
    .addSection(
      CardService.newCardSection()
        .addWidget(
          CardService.newTextParagraph()
            .setText(`An error occurred: ${errorMessage}`)
        )
    )
    .build();
}

Viewing Logs:

  1. In Apps Script editor: ViewExecutions
  2. Click on any execution to see its logs
  3. Logs show console.log() output, errors, and execution time

Step 4: Understanding OAuth Scopes and Security

Security is critical for internal add-ons. Let’s understand how OAuth scopes work.

Common OAuth Scopes

ScopePurposeRisk Level
gmail.addons.current.message.readonlyRead currently opened emailLow
gmail.addons.current.message.actionCompose emails from add-onMedium
gmail.readonlyRead all emails in mailboxHigh
gmail.modifyModify emails (labels, etc.)High
script.external_requestCall external APIsMedium
spreadsheetsAccess Google SheetsMedium

Security Best Practices:

  1. Request minimum scopes: Only request permissions you actually need
  2. Use .current.message.* scopes: These are safer than full mailbox access
  3. Validate all inputs: Never trust data from emails or users
  4. Sanitize API responses: Don’t display untrusted HTML content
  5. Use HTTPS for external APIs: Always encrypt data in transit
  6. Store secrets securely: Use Script Properties, never hardcode API keys

Storing API Keys Securely

Never hardcode API keys in your script. Use Properties Service instead:

/**
 * Set up your API key (run once, manually)
 */
function setupApiKey() {
  const scriptProperties = PropertiesService.getScriptProperties();
  scriptProperties.setProperty('CRM_API_KEY', 'your-actual-api-key-here');
  scriptProperties.setProperty('CRM_API_URL', 'https://your-crm.com/api');
}

/**
 * Use the API key in your code
 */
function fetchCustomerData(customerId) {
  const scriptProperties = PropertiesService.getScriptProperties();
  const apiKey = scriptProperties.getProperty('CRM_API_KEY');
  const apiUrl = scriptProperties.getProperty('CRM_API_URL');
  
  const response = UrlFetchApp.fetch(`${apiUrl}/customers/${customerId}`, {
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error('Failed to fetch customer data');
  }
  
  return JSON.parse(response.getContentText());
}

To set properties:

  1. Copy the setupApiKey() function into your script
  2. Update with your actual API credentials
  3. Select setupApiKey from the function dropdown
  4. Click Run
  5. Delete or comment out the function after running

Step 5: Deploying Organization-Wide

Once tested, deploy the add-on for your entire organization.

5.1 Create Organization Deployment

  1. In Apps Script editor: DeployNew deployment
  2. Click gear icon → Add-on
  3. Configure deployment:
    • Description: “Customer Info Add-on for internal use”
    • Version: “Version 1”
    • Execute as: “User accessing the add-on” (recommended)
    • Who has access: Select “Anyone within your organization”
  4. Click Deploy
  5. Copy the Deployment ID (you’ll need this for admin console)

5.2 Configure in Google Workspace Admin Console

Now publish the add-on through the Admin Console:

  1. Navigate to admin.google.com
  2. Go to AppsGoogle Workspace Marketplace apps
  3. Click Add appAdd custom app
  4. Paste your Deployment ID
  5. Review permissions and click Add
  6. Choose installation settings:
    • Install for everyone: Automatic installation for all users
    • Available to install: Users can install from sidebar
  7. Click Continue and Finish

5.3 Installation Propagation

  • Changes take up to 24 hours to propagate fully
  • Most users see updates within 1-2 hours
  • Users may need to refresh Gmail or re-login

Verification:

  1. Have test users check their Gmail add-on sidebar
  2. Check Admin Console reports for installation status
  3. Monitor Apps Script execution logs for usage

Step 6: Advanced Features

6.1 Adding User Configuration

Let users customize the add-on behavior:

/**
 * Show configuration card on homepage
 */
function onHomepage() {
  const userProperties = PropertiesService.getUserProperties();
  const autoShow = userProperties.getProperty('autoShow') === 'true';
  
  const card = CardService.newCardBuilder()
    .setHeader(
      CardService.newCardHeader()
        .setTitle('Customer Info Add-on')
        .setSubtitle('Configure your preferences')
    )
    .addSection(
      CardService.newCardSection()
        .setHeader('Settings')
        .addWidget(
          CardService.newSelectionInput()
            .setType(CardService.SelectionInputType.CHECK_BOX)
            .setFieldName('autoShow')
            .addItem('Automatically show customer info', 'true', autoShow)
        )
        .addWidget(
          CardService.newButtonSet()
            .addButton(
              CardService.newTextButton()
                .setText('Save Settings')
                .setOnClickAction(
                  CardService.newAction()
                    .setFunctionName('saveSettings')
                )
            )
        )
    )
    .build();
  
  return card;
}

/**
 * Save user preferences
 */
function saveSettings(e) {
  const userProperties = PropertiesService.getUserProperties();
  const autoShow = e.formInput.autoShow ? e.formInput.autoShow[0] : 'false';
  
  userProperties.setProperty('autoShow', autoShow);
  
  return CardService.newActionResponseBuilder()
    .setNotification(
      CardService.newNotification()
        .setText('Settings saved successfully')
    )
    .build();
}

6.2 Caching for Performance

Reduce API calls and improve response time with caching:

/**
 * Fetch customer data with caching
 */
function fetchCustomerData(customerId) {
  const cache = CacheService.getUserCache();
  const cacheKey = `customer_${customerId}`;
  
  // Try to get from cache first
  const cached = cache.get(cacheKey);
  if (cached) {
    console.log('Returning cached data for:', customerId);
    return JSON.parse(cached);
  }
  
  // Not in cache, fetch from API
  console.log('Fetching fresh data for:', customerId);
  const scriptProperties = PropertiesService.getScriptProperties();
  const apiKey = scriptProperties.getProperty('CRM_API_KEY');
  const apiUrl = scriptProperties.getProperty('CRM_API_URL');
  
  const response = UrlFetchApp.fetch(`${apiUrl}/customers/${customerId}`, {
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error('Failed to fetch customer data');
  }
  
  const data = JSON.parse(response.getContentText());
  
  // Cache for 10 minutes (600 seconds)
  cache.put(cacheKey, JSON.stringify(data), 600);
  
  return data;
}

Cache Benefits:

  • Faster response times (no API latency)
  • Reduced API quota usage
  • Better reliability if external API is temporarily down

Cache Limitations:

  • Maximum 100KB per item
  • 1MB total per user
  • Data expires after specified time or 6 hours max

6.3 Supporting Multiple Workspace Apps

Extend your add-on to work in Calendar, Drive, or Docs:

{
  "timeZone": "America/New_York",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
    "https://www.googleapis.com/auth/calendar.addons.execute",
    "https://www.googleapis.com/auth/drive.addons.metadata.readonly",
    "https://www.googleapis.com/auth/script.external_request"
  ],
  "addOns": {
    "common": {
      "name": "Customer Info",
      "logoUrl": "https://www.gstatic.com/images/branding/product/1x/apps_script_48dp.png",
      "homepageTrigger": {
        "runFunction": "onHomepage"
      }
    },
    "gmail": {
      "contextualTriggers": [
        {
          "unconditional": {},
          "onTriggerFunction": "onGmailMessageOpen"
        }
      ]
    },
    "calendar": {
      "eventOpenTrigger": {
        "runFunction": "onCalendarEventOpen"
      }
    }
  }
}

Then add handlers for each app:

/**
 * Calendar event handler
 */
function onCalendarEventOpen(e) {
  const eventId = e.calendar.id;
  const attendees = e.calendar.attendees || [];
  
  // Find customers from attendee list
  const customers = attendees
    .map(a => extractCustomerId({payload: {headers: [{name: 'From', value: a.email}]}}))
    .filter(id => id !== 'unknown');
  
  if (customers.length === 0) {
    return buildNoCustomersCard();
  }
  
  // Show first customer
  const customerData = fetchCustomerData(customers[0]);
  return buildCustomerCard(customerData);
}

function buildNoCustomersCard() {
  return CardService.newCardBuilder()
    .setHeader(
      CardService.newCardHeader()
        .setTitle('Customer Info')
    )
    .addSection(
      CardService.newCardSection()
        .addWidget(
          CardService.newTextParagraph()
            .setText('No customer contacts found in this event.')
        )
    )
    .build();
}

Step 7: Production Best Practices

7.1 Error Handling and Resilience

Production add-ons need robust error handling:

/**
 * Wrapper with comprehensive error handling
 */
function onGmailMessageOpen(e) {
  try {
    // Validate event object
    if (!e || !e.gmail || !e.gmail.messageId) {
      throw new Error('Invalid event object');
    }
    
    const messageId = e.gmail.messageId;
    const accessToken = e.gmail.accessToken;
    
    // Get message with retry logic
    const message = getMessageWithRetry(messageId, accessToken);
    
    const customerId = extractCustomerId(message);
    
    // Validate customer ID
    if (!customerId || customerId === 'unknown') {
      return buildNoCustomerCard();
    }
    
    const customerData = fetchCustomerDataSafe(customerId);
    
    return buildCustomerCard(customerData);
    
  } catch (error) {
    console.error('Error in onGmailMessageOpen:', error);
    logError(error, e);
    return buildErrorCard(error.message);
  }
}

/**
 * Retry logic for API calls
 */
function getMessageWithRetry(messageId, accessToken, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return getCurrentMessage(messageId, accessToken);
    } catch (error) {
      console.log(`Attempt ${i + 1} failed:`, error.message);
      if (i === maxRetries - 1) throw error;
      Utilities.sleep(1000 * Math.pow(2, i)); // Exponential backoff
    }
  }
}

/**
 * Safe customer data fetch with fallback
 */
function fetchCustomerDataSafe(customerId) {
  try {
    return fetchCustomerData(customerId);
  } catch (error) {
    console.error('Failed to fetch customer data:', error);
    // Return minimal fallback data
    return {
      id: customerId,
      name: 'Unknown Customer',
      status: 'Data unavailable',
      error: true
    };
  }
}

/**
 * Log errors to spreadsheet for monitoring
 * Setup: Create a Google Sheet, get its ID from the URL, and store it:
 * PropertiesService.getScriptProperties().setProperty('LOG_SHEET_ID', 'your-sheet-id-here');
 */
function logError(error, context) {
  try {
    const scriptProperties = PropertiesService.getScriptProperties();
    const sheetId = scriptProperties.getProperty('LOG_SHEET_ID');
    
    if (!sheetId) {
      console.warn('LOG_SHEET_ID not configured - skipping error logging to sheet');
      return;
    }
    
    const sheet = SpreadsheetApp.openById(sheetId).getActiveSheet();
    sheet.appendRow([
      new Date(),
      error.message,
      error.stack || '',
      JSON.stringify(context)
    ]);
  } catch (logError) {
    console.error('Failed to log error:', logError);
  }
}

7.2 Performance Optimization

Key optimization strategies:

  1. Minimize API Calls: Batch requests when possible
  2. Cache Aggressively: Use CacheService for frequently accessed data
  3. Lazy Load: Only fetch data when actually needed
  4. Optimize Payload: Request only fields you need from APIs
/**
 * Optimized Gmail message fetch - only get headers
 */
function getCurrentMessage(messageId, accessToken) {
  const url = `https://www.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From`;
  
  const response = UrlFetchApp.fetch(url, {
    headers: { 'Authorization': `Bearer ${accessToken}` },
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`Gmail API error: ${response.getResponseCode()}`);
  }
  
  return JSON.parse(response.getContentText());
}

/**
 * Batch fetch multiple customers
 */
function fetchMultipleCustomers(customerIds) {
  const cache = CacheService.getUserCache();
  const uncached = [];
  const results = {};
  
  // Check cache first
  customerIds.forEach(id => {
    const cached = cache.get(`customer_${id}`);
    if (cached) {
      results[id] = JSON.parse(cached);
    } else {
      uncached.push(id);
    }
  });
  
  // Batch fetch uncached
  if (uncached.length > 0) {
    const scriptProperties = PropertiesService.getScriptProperties();
    const apiUrl = scriptProperties.getProperty('CRM_API_URL');
    const apiKey = scriptProperties.getProperty('CRM_API_KEY');
    
    const response = UrlFetchApp.fetch(`${apiUrl}/customers/batch`, {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      },
      payload: JSON.stringify({ ids: uncached })
    });
    
    const batchData = JSON.parse(response.getContentText());
    
    // Cache and store results
    batchData.forEach(customer => {
      cache.put(`customer_${customer.id}`, JSON.stringify(customer), 600);
      results[customer.id] = customer;
    });
  }
  
  return results;
}

7.3 Monitoring and Analytics

Track add-on usage and performance:

/**
 * Log usage metrics
 * Setup: Create a Google Sheet for metrics with columns: Timestamp, User, Action, Metadata
 * Get the sheet ID from URL and store: 
 * PropertiesService.getScriptProperties().setProperty('METRICS_SHEET_ID', 'your-sheet-id-here');
 */
function logUsage(action, metadata = {}) {
  const userEmail = Session.getActiveUser().getEmail();
  const timestamp = new Date();
  
  const logEntry = {
    timestamp: timestamp.toISOString(),
    user: userEmail,
    action: action,
    metadata: metadata
  };
  
  // Log to spreadsheet
  try {
    const scriptProperties = PropertiesService.getScriptProperties();
    const sheetId = scriptProperties.getProperty('METRICS_SHEET_ID');
    
    if (!sheetId) {
      console.warn('METRICS_SHEET_ID not configured - skipping metrics logging');
      return;
    }
    
    const sheet = SpreadsheetApp.openById(sheetId)
      .getSheetByName('Usage Logs');
    
    if (!sheet) {
      console.error('Sheet "Usage Logs" not found in metrics spreadsheet');
      return;
    }
    
    sheet.appendRow([
      timestamp,
      userEmail,
      action,
      JSON.stringify(metadata)
    ]);
  } catch (error) {
    console.error('Failed to log usage:', error);
  }
}

/**
 * Enhanced message open handler with metrics
 */
function onGmailMessageOpen(e) {
  const startTime = Date.now();
  
  try {
    const result = processEmailOpen(e);
    
    logUsage('message_open', {
      duration: Date.now() - startTime,
      success: true
    });
    
    return result;
    
  } catch (error) {
    logUsage('message_open', {
      duration: Date.now() - startTime,
      success: false,
      error: error.message
    });
    
    throw error;
  }
}

7.4 Version Management

Maintain multiple versions for testing and rollback:

  1. Semantic Versioning: Use version numbers like 1.0.0, 1.1.0, 2.0.0
  2. Test Deployments: Always test new versions before org-wide rollout
  3. Gradual Rollout: Deploy to pilot group first
  4. Keep Previous Version: Don’t delete old deployments immediately

Version Deployment Strategy:

Version 1.0.0 (Production)
├─ Deploy to: Everyone
└─ Status: Stable

Version 1.1.0 (Beta)
├─ Deploy to: Beta testers group
└─ Status: Testing

Version 1.2.0 (Development)
├─ Deploy to: Test deployments only
└─ Status: In development

Step 8: Troubleshooting Common Issues

Issue 1: Add-on Not Appearing

Symptoms: Add-on doesn’t show in Gmail sidebar

Solutions:

  • Wait up to 24 hours for propagation
  • Check Admin Console installation status
  • Verify user is in correct organizational unit
  • Clear browser cache and reload Gmail
  • Try incognito mode
  • Check if add-on is enabled in Gmail settings

Issue 2: Authorization Errors

Symptoms: “Authorization required” or scope errors

Solutions:

// Verify scopes in appsscript.json
{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
    "https://www.googleapis.com/auth/script.external_request"
  ]
}
  • Ensure all required scopes are listed
  • Re-authorize: Remove add-on and reinstall
  • Check Admin Console for restricted scopes
  • Verify script hasn’t been disabled by admin

Issue 3: External API Calls Failing

Symptoms: Timeout or connection errors

Solutions:

function makeExternalApiCall(url) {
  // Get API credentials securely
  const scriptProperties = PropertiesService.getScriptProperties();
  const apiToken = scriptProperties.getProperty('EXTERNAL_API_TOKEN');
  
  if (!apiToken) {
    throw new Error('API token not configured');
  }
  
  try {
    const response = UrlFetchApp.fetch(url, {
      headers: {
        'Authorization': `Bearer ${apiToken}`
      },
      muteHttpExceptions: true,
      validateHttpsCertificates: true
    });
    
    const code = response.getResponseCode();
    
    if (code === 429) {
      throw new Error('Rate limit exceeded - try again later');
    }
    
    if (code >= 400) {
      throw new Error(`API error: ${code} - ${response.getContentText()}`);
    }
    
    return JSON.parse(response.getContentText());
    
  } catch (error) {
    console.error('API call failed:', error);
    throw error;
  }
}
  • Check API endpoint is HTTPS
  • Verify API key/token is valid
  • Check Apps Script quotas: script.google.com/home/usersettings
  • Implement retry logic with exponential backoff
  • Check if domain needs whitelisting in Workspace Admin

Issue 4: Slow Performance

Symptoms: Add-on takes >5 seconds to load

Solutions:

  • Implement caching (see Section 6.2)
  • Reduce payload size from external APIs
  • Optimize image loading (use lower resolution)
  • Minimize Card complexity (fewer widgets)
  • Profile execution time:
function profileExecution() {
  const steps = {};
  
  steps.start = Date.now();
  
  const message = getCurrentMessage();
  steps.getMessage = Date.now();
  
  const customerId = extractCustomerId(message);
  steps.extractId = Date.now();
  
  const data = fetchCustomerData(customerId);
  steps.fetchData = Date.now();
  
  const card = buildCustomerCard(data);
  steps.buildCard = Date.now();
  
  // Log timing breakdown
  console.log('Timing breakdown:');
  console.log('Get message:', steps.getMessage - steps.start, 'ms');
  console.log('Extract ID:', steps.extractId - steps.getMessage, 'ms');
  console.log('Fetch data:', steps.fetchData - steps.extractId, 'ms');
  console.log('Build card:', steps.buildCard - steps.fetchData, 'ms');
  console.log('Total:', steps.buildCard - steps.start, 'ms');
  
  return card;
}

Issue 5: Quota Exceeded Errors

Apps Script Quotas (Free workspace accounts):

  • Email sends: 100/day
  • URL fetches: 20,000/day
  • Execution time: 6 minutes/execution
  • Triggers: 20 concurrent

Solutions:

  • Implement aggressive caching
  • Batch API requests
  • Optimize execution paths
  • Consider upgrading to paid workspace
  • Monitor quota usage in Apps Script dashboard

Understanding the Apps Script Execution Environment

The V8 Runtime

Apps Script uses Google’s V8 JavaScript engine (same as Chrome and Node.js), providing:

Modern JavaScript Features:

// ES6+ features fully supported
const fetchData = async (id) => {
  const response = await UrlFetchApp.fetch(`https://api.example.com/data/${id}`);
  return JSON.parse(response.getContentText());
};

// Destructuring
const {customerId, companyName} = extractCustomerInfo(message);

// Template literals
const url = `${baseUrl}/customers/${customerId}/details`;

// Spread operator
const allData = {...basicInfo, ...additionalDetails};

// Arrow functions
const customerIds = emails.map(e => extractId(e)).filter(id => id !== null);

Key Characteristics:

AspectDetails
RuntimeV8 (Chrome/Node.js engine)
JavaScript VersionES6+ (ECMAScript 2015+)
Execution ModelServerless, ephemeral containers
State PersistenceUse PropertiesService or external storage
Cold StartsInitial execution after inactivity is slower
QuotasPer-user and per-day limits apply

Serverless Considerations

Statelessness: Each execution is independent

// ❌ DON'T: Global variables reset between executions
let cachedData = null;

function getData() {
  if (!cachedData) {
    cachedData = fetchData(); // This won't persist!
  }
  return cachedData;
}

// ✓ DO: Use CacheService for persistence
function getData() {
  const cache = CacheService.getUserCache();
  let data = cache.get('myData');
  
  if (!data) {
    data = fetchData();
    cache.put('myData', JSON.stringify(data), 600);
  } else {
    data = JSON.parse(data);
  }
  
  return data;
}

Advanced Architecture Patterns

Pattern 1: Multi-Source Data Aggregation

Combine data from multiple sources:

function onGmailMessageOpen(e) {
  const message = getCurrentMessage(e.gmail.messageId, e.gmail.accessToken);
  const customerId = extractCustomerId(message);
  
  // Fetch from multiple sources in parallel
  const results = fetchMultipleSources(customerId);
  
  return buildAggregatedCard(results);
}

function fetchMultipleSources(customerId) {
  // Apps Script doesn't support true parallelism, but this pattern
  // minimizes sequential delays
  const sources = {
    crm: null,
    support: null,
    billing: null,
    analytics: null
  };
  
  try {
    sources.crm = fetchFromCRM(customerId);
  } catch (e) {
    console.error('CRM fetch failed:', e);
  }
  
  try {
    sources.support = fetchFromSupport(customerId);
  } catch (e) {
    console.error('Support fetch failed:', e);
  }
  
  try {
    sources.billing = fetchFromBilling(customerId);
  } catch (e) {
    console.error('Billing fetch failed:', e);
  }
  
  try {
    sources.analytics = fetchFromAnalytics(customerId);
  } catch (e) {
    console.error('Analytics fetch failed:', e);
  }
  
  return sources;
}

function buildAggregatedCard(data) {
  const card = CardService.newCardBuilder();
  
  card.setHeader(
    CardService.newCardHeader()
      .setTitle('Customer Dashboard')
  );
  
  // Add section for each data source that succeeded
  if (data.crm) {
    card.addSection(buildCRMSection(data.crm));
  }
  
  if (data.support) {
    card.addSection(buildSupportSection(data.support));
  }
  
  if (data.billing) {
    card.addSection(buildBillingSection(data.billing));
  }
  
  if (data.analytics) {
    card.addSection(buildAnalyticsSection(data.analytics));
  }
  
  return card.build();
}

Pattern 2: Workflow Automation

Automate multi-step processes:

/**
 * Handle button click to create support ticket
 */
function createSupportTicket(e) {
  const customerId = e.parameters.customerId;
  const customerEmail = e.parameters.customerEmail;
  
  try {
    // Step 1: Create ticket in support system
    const ticket = createTicketInSystem(customerId);
    
    // Step 2: Log activity in CRM
    logActivityInCRM(customerId, 'support_ticket_created', ticket.id);
    
    // Step 3: Send notification email
    sendTicketNotification(customerEmail, ticket);
    
    // Step 4: Update UI
    return CardService.newActionResponseBuilder()
      .setNotification(
        CardService.newNotification()
          .setText(`Ticket #${ticket.id} created successfully`)
      )
      .setStateChanged(true)
      .build();
      
  } catch (error) {
    return CardService.newActionResponseBuilder()
      .setNotification(
        CardService.newNotification()
          .setText(`Failed to create ticket: ${error.message}`)
      )
      .build();
  }
}

function sendTicketNotification(email, ticket) {
  GmailApp.sendEmail(
    email,
    `Support Ticket #${ticket.id} Created`,
    `Your support ticket has been created.\n\nTicket ID: ${ticket.id}\nStatus: ${ticket.status}\n\nView ticket: ${ticket.url}`
  );
}

Pattern 3: Progressive Enhancement

Start with basic functionality, add features progressively:

function buildCustomerCard(customer) {
  const card = CardService.newCardBuilder();
  
  // Level 1: Basic info (always available)
  card.setHeader(
    CardService.newCardHeader()
      .setTitle(customer.name || 'Unknown Customer')
  );
  
  const basicSection = CardService.newCardSection()
    .setHeader('Basic Information');
  
  if (customer.id) {
    basicSection.addWidget(
      CardService.newKeyValue()
        .setTopLabel('Customer ID')
        .setContent(customer.id)
    );
  }
  
  card.addSection(basicSection);
  
  // Level 2: Enhanced data (if available)
  if (customer.accountManager) {
    const teamSection = CardService.newCardSection()
      .setHeader('Team');
    
    teamSection.addWidget(
      CardService.newKeyValue()
        .setTopLabel('Account Manager')
        .setContent(customer.accountManager)
        .setIcon(CardService.Icon.PERSON)
    );
    
    card.addSection(teamSection);
  }
  
  // Level 3: Real-time data (if available)
  if (customer.liveMetrics) {
    const metricsSection = CardService.newCardSection()
      .setHeader('Live Metrics');
    
    metricsSection.addWidget(
      CardService.newKeyValue()
        .setTopLabel('Active Users')
        .setContent(String(customer.liveMetrics.activeUsers))
    );
    
    card.addSection(metricsSection);
  }
  
  // Level 4: Actions (if permissions allow)
  if (hasPermission('create_tickets')) {
    const actionsSection = CardService.newCardSection();
    
    actionsSection.addWidget(
      CardService.newButtonSet()
        .addButton(
          CardService.newTextButton()
            .setText('Create Support Ticket')
            .setOnClickAction(
              CardService.newAction()
                .setFunctionName('createSupportTicket')
                .setParameters({
                  customerId: customer.id,
                  customerEmail: customer.email
                })
            )
        )
    );
    
    card.addSection(actionsSection);
  }
  
  return card.build();
}

Production Deployment Checklist

Before deploying to your organization, ensure:

  • Testing Complete

    • Tested in test deployment with multiple users
    • Verified all error paths work correctly
    • Confirmed performance is acceptable (<3s load time)
    • Tested with different email types
  • Security Review

    • Minimum OAuth scopes requested
    • API keys stored in Properties (not hardcoded)
    • Input validation implemented
    • External API calls use HTTPS
    • Error messages don’t expose sensitive data
  • Monitoring Setup

    • Error logging configured
    • Usage metrics collection active
    • Admin notification for critical errors
    • Dashboard for monitoring adoption
  • Documentation

    • User guide created
    • Admin deployment guide written
    • Troubleshooting section documented
    • Support contact provided
  • Compliance

    • Privacy policy reviewed
    • Data handling documented
    • Retention policies defined
    • Access controls configured
  • Rollout Plan

    • Pilot group identified (5-10 users)
    • Feedback mechanism established
    • Timeline for org-wide rollout set
    • Rollback procedure documented

Conclusion

You’ve now built a complete, production-ready Google Workspace Add-on from scratch. This walkthrough covered:

Setup: Creating Apps Script projects and manifest configuration
Development: Building UIs with Card Service and implementing business logic
Integration: Connecting to external APIs securely
Testing: Comprehensive debugging and validation
Deployment: Organization-wide rollout through Admin Console
Production: Error handling, monitoring, performance optimization
Advanced Patterns: Multi-source aggregation, workflow automation, progressive enhancement

Key Takeaways

  1. Start Simple: Begin with basic functionality, add features iteratively
  2. Security First: Always use minimum OAuth scopes and secure credential storage
  3. Cache Aggressively: Reduce API calls and improve performance with caching
  4. Handle Errors Gracefully: Provide fallbacks and helpful error messages
  5. Monitor Usage: Track metrics to understand adoption and issues
  6. Test Thoroughly: Use test deployments before org-wide rollout

Next Steps

Now that you have a working add-on:

  1. Customize the example to your organization’s needs
  2. Connect to your actual internal APIs and data sources
  3. Expand to Calendar, Drive, or other Workspace apps
  4. Share your success with stakeholders
  5. Iterate based on user feedback

Resources

Internal Google Workspace Add-ons represent a powerful way to extend your organization’s productivity tools with custom functionality that perfectly fits your workflows. With the foundation you’ve built here, you’re ready to create sophisticated internal tools that will streamline operations and delight your users.

Thank you for reading! If you have any feedback or comments, please send them to [email protected] or contact the author directly at [email protected].