Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build Google Apps Script automation for Sheets and Workspace. Custom menus, triggers (onEdit / time-driven / form submit), dialogs, sidebars, email batches, PDF export, external API. Use whenever the user wants to automate a Google Sheet, build a Sheets menu / sidebar / dialog, hit a Sheets row from email or a webhook, schedule a Sheets workflow, or asks 'how do I script this in Sheets'.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 170% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 172% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 75% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 135% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 335% | 0% |
<!-- source: google-apps-script-sheets — https://raw.githubusercontent.com/jezweb/claude-skills/main/plugins/integrations/skills/google-apps-script/SKILL.md -->
Build automation scripts for Google Sheets and Workspace apps. Scripts run server-side on Google's infrastructure with a generous free tier.
Ask what the user wants automated. Common scenarios:
Follow the structure template below. Every script needs a header comment, configuration constants at top, and onOpen() for menu setup.
All scripts install the same way:
Each user gets a Google OAuth consent screen on first run. For unverified scripts (most internal scripts), users must click:
Advanced > Go to Project Name] (unsafe) > Allow
This is a one-time step per user. Warn users about this in your output.
Every script should follow this pattern:
javascript/** * [Project Name] - [Brief Description] * * [What it does, key features] * * INSTALL: Extensions > Apps Script > paste this > Save > Reload sheet */ // --- CONFIGURATION --- const SOME_SETTING = 'value'; // --- MENU SETUP --- function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu('My Menu') .addItem('Do Something', 'myFunction') .addSeparator() .addSubMenu(ui.createMenu('More Options') .addItem('Option A', 'optionA')) .addToUi(); } // --- FUNCTIONS --- function myFunction() { // Implementation }
Functions ending with _ (underscore) are private and CANNOT be called from client-side HTML via google.script.run. This is a silent failure -- the call simply doesn't work with no error.
javascript// WRONG - dialog can't call this, fails silently function doWork_() { return 'done'; } // RIGHT - dialog can call this function doWork() { return 'done'; }
Also applies to: Menu item function references must be public function names as strings.
Read/write data in bulk, never cell-by-cell. The difference is 70x.
javascript// SLOW (70 seconds on 100x100) - reads one cell at a time for (let i = 1; i <= 100; i++) { const val = sheet.getRange(i, 1).getValue(); } // FAST (1 second) - reads all at once const allData = sheet.getRange(1, 1, 100, 1).getValues(); for (const row of allData) { const val = row[0]; }
Always use getRange().getValues() / setValues() for bulk reads/writes.
V8 is the only runtime (Rhino was removed January 2026). Supports modern JavaScript: const, let, arrow functions, template literals, destructuring, classes, async/generators.
NOT available (use Apps Script alternatives):
| Missing API | Apps Script Alternative | |-------------|------------------------| | setTimeout / setInterval | Utilities.sleep(ms) (blocking) | | fetch | UrlFetchApp.fetch() | | FormData | Build payload manually | | URL | String manipulation | | crypto | Utilities.computeDigest() / Utilities.getUuid() |
Call SpreadsheetApp.flush() before returning from functions that modify the sheet, especially when called from HTML dialogs. Without it, changes may not be visible when the dialog shows "Done."
| Feature | Simple (onEdit) | Installable | |---------|-------------------|-------------| | Auth required | No | Yes | | Send email | No | Yes | | Access other files | No | Yes | | URL fetch | No | Yes | | Open dialogs | No | Yes | | Runs as | Active user | Trigger creator |
Use simple triggers for lightweight reactions. Use installable triggers (via ScriptApp.newTrigger()) when you need email, external APIs, or cross-file access.
Functions used as =MY_FUNCTION() in cells have strict limitations:
javascript/** * Calculates something custom. * @param {string} input The input value * @return {string} The result * @customfunction */ function MY_FUNCTION(input) { // Can use: basic JS, Utilities, CacheService // CANNOT use: MailApp, UrlFetchApp, SpreadsheetApp.getUi(), triggers return input.toUpperCase(); }
@customfunction JSDoc tag| Resource | Free Account | Google Workspace | |----------|-------------|-----------------| | Script runtime | 6 min / execution | 6 min / execution | | Time-driven trigger runtime | 30 min | 30 min | | Triggers total daily runtime | 90 min | 6 hours | | Triggers total | 20 per user per script | 20 per user per script | | Email recipients/day | 100 | 1,500 | | URL Fetch calls/day | 20,000 | 100,000 | | Properties storage | 500 KB | 500 KB | | Custom function runtime | 30 seconds | 30 seconds | | Simultaneous executions | 30 | 30 |
Block user interaction during long operations with a spinner that auto-closes. Use for any operation taking more than a few seconds.
Pattern: menu function > showProgress() > dialog calls action function > auto-close
javascriptfunction showProgress(message, serverFn) { const html = HtmlService.createHtmlOutput(` <style> body { font-family: 'Google Sans', Arial, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; margin: 0; padding: 20px; box-sizing: border-box; } .spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0; border-top: 4px solid #1a73e8; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px; } @keyframes spin { to { transform: rotate(360deg); } } .message { font-size: 14px; color: #333; text-align: center; } .done { color: #1e8e3e; font-weight: 500; } .error { color: #d93025; font-weight: 500; } </style> <div class="spinner" id="spinner"></div> <div class="message" id="msg">${message}</div> <script> google.script.run .withSuccessHandler(function(r) { document.getElementById('spinner').style.display = 'none'; var m = document.getElementById('msg'); m.className = 'message done'; m.innerText = 'Done! ' + (r || ''); setTimeout(function() { google.script.host.close(); }, 1200); }) .withFailureHandler(function(err) { document.getElementById('spinner').style.display = 'none'; var m = document.getElementById('msg'); m.className = 'message error'; m.innerText = 'Error: ' + err.message; setTimeout(function() { google.script.host.close(); }, 3000); }) .${serverFn}(); </script> `).setWidth(320).setHeight(140); SpreadsheetApp.getUi().showModalDialog(html, 'Working...'); } // Menu calls this wrapper function menuDoWork() { showProgress('Processing data...', 'doTheWork'); } // MUST be public (no underscore) for the dialog to call it function doTheWork() { // ... do the work ... SpreadsheetApp.flush(); return 'Processed 50 rows'; // shown in success message }
javascriptSpreadsheetApp.getActiveSpreadsheet().toast('Operation complete!', 'Title', 5); // Arguments: message, title, duration in seconds (-1 = until dismissed)
javascriptconst ui = SpreadsheetApp.getUi(); // Yes/No confirmation const response = ui.alert('Delete this data?', 'This cannot be undone.', ui.ButtonSet.YES_NO); if (response === ui.Button.YES) { /* proceed */ } // Prompt for input const result = ui.prompt('Enter your name:', ui.ButtonSet.OK_CANCEL); if (result.getSelectedButton() === ui.Button.OK) { const name = result.getResponseText(); }
HTML panel on the right. Use google.script.run to call server functions.
javascriptfunction showSidebar() { const html = HtmlService.createHtmlOutput(` <h3>Quick Entry</h3> <select id="worker"><option>Craig</option><option>Steve</option></select> <input id="suburb" placeholder="Suburb"> <button onclick="submit()">Add Job</button> <script> function submit() { google.script.run.withSuccessHandler(function() { alert('Added!'); }) .addJob(document.getElementById('worker').value, document.getElementById('suburb').value); } </script> `).setTitle('Job Entry').setWidth(300); SpreadsheetApp.getUi().showSidebar(html); } function addJob(worker, suburb) { // MUST be public (no underscore) SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().appendRow([new Date(), worker, suburb]); }
onEdit (simple trigger) -- limited permissions but no auth needed:
javascriptfunction onEdit(e) { const sheet = e.source.getActiveSheet(); if (sheet.getName() !== 'Data') return; if (e.range.getColumn() !== 3) return; // Auto-timestamp when column C is edited sheet.getRange(e.range.getRow(), 4).setValue(new Date()); }
Installable triggers -- create via script, run setup function once manually:
javascriptfunction createTriggers() { // Time-driven: run every day at 8am ScriptApp.newTrigger('dailyReport') .timeBased().atHour(8).everyDays(1).create(); // On edit with full permissions (can send email, fetch URLs) ScriptApp.newTrigger('onEditFull') .forSpreadsheet(SpreadsheetApp.getActive()).onEdit().create(); // On form submit ScriptApp.newTrigger('onFormSubmit') .forSpreadsheet(SpreadsheetApp.getActive()).onFormSubmit().create(); }
javascriptfunction emailWeeklySchedule() { const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); const data = sheet.getRange('A2:E10').getDisplayValues(); let body = '<h2>Weekly Schedule</h2><table border="1" cellpadding="8">'; body += '<tr><th>Job</th><th>Suburb</th><th>Time</th><th>Price</th></tr>'; for (const row of data) { if (row[0]) body += '<tr>' + row.map(c => '<td>' + c + '</td>').join('') + '</tr>'; } body += '</table>'; MailApp.sendEmail({ to: 'worker@example.com', subject: 'Schedule - Week ' + sheet.getName(), htmlBody: body }); }
Non-obvious URL construction -- export parameters are undocumented:
javascriptfunction exportSheetAsPdf() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const url = ss.getUrl().replace(/\/edit.*$/, '') + '/export?exportFormat=pdf&format=pdf&size=A4&portrait=true' + '&fitw=true&sheetnames=false&printtitle=false&gridlines=false' + '&gid=' + ss.getActiveSheet().getSheetId(); const blob = UrlFetchApp.fetch(url, { headers: { 'Authorization': 'Bearer ' + ScriptApp.getOAuthToken() } }).getBlob().setName('report.pdf'); MailApp.sendEmail({ to: 'boss@example.com', subject: 'Weekly Report PDF', body: 'Attached.', attachments: [blob] }); }
javascript// GET function fetchData() { const r = UrlFetchApp.fetch('https://api.example.com/data', { headers: { 'Authorization': 'Bearer ' + getApiKey() } }); return JSON.parse(r.getContentText()); } // POST (muteHttpExceptions to handle errors yourself) function postData(payload) { const r = UrlFetchApp.fetch('https://api.example.com/submit', { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }); if (r.getResponseCode() !== 200) throw new Error('API error: ' + r.getContentText()); return JSON.parse(r.getContentText()); }
javascript// Dropdown from list const rule = SpreadsheetApp.newDataValidation() .requireValueInList(['Option A', 'Option B', 'Option C'], true) .setAllowInvalid(false).setHelpText('Select an option').build(); sheet.getRange('C3:C50').setDataValidation(rule); // Dropdown from range (e.g. a Lookups sheet) const rule2 = SpreadsheetApp.newDataValidation() .requireValueInRange(ss.getSheetByName('Lookups').getRange('A1:A100')).build(); sheet.getRange('B3:B50').setDataValidation(rule2);
Three scopes: PropertiesService.getScriptProperties() (shared), .getUserProperties() (per user), .getDocumentProperties() (per spreadsheet). All use .setProperty(key, value) / .getProperty(key). 500 KB limit.
Move rows with "Complete" status to an Archive sheet. Processes bottom-up to avoid shifting row indices.
javascriptfunction archiveCompleted() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const source = ss.getSheetByName('Active'); const archive = ss.getSheetByName('Archive'); const data = source.getDataRange().getValues(); const statusCol = 4; // column E (0-indexed) for (let i = data.length - 1; i >= 1; i--) { if (data[i][statusCol] === 'Complete') { archive.appendRow(data[i]); source.deleteRow(i + 1); // +1 for 1-indexed rows } } SpreadsheetApp.flush(); }
Pattern: read column with getValues(), track seen values in an object, highlight both the original and duplicate rows with setBackground('#f4cccc'). Process all data in one getValues() call, then set backgrounds individually (unavoidable for scattered highlights).
Key pattern: check MailApp.getRemainingDailyQuota() before sending, mark status per row, wrap each send in try/catch.
javascriptfunction sendBatchEmails() { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Recipients'); const data = sheet.getRange('A2:C' + sheet.getLastRow()).getValues(); // Email, Name, Status const remaining = MailApp.getRemainingDailyQuota(); if (remaining < data.length) { SpreadsheetApp.getUi().alert('Only ' + remaining + ' emails left. Need ' + data.length); return; } let sent = 0; for (let i = 0; i < data.length; i++) { const [email, name, status] = data[i]; if (!email || status === 'Sent') continue; try { MailApp.sendEmail({ to: email, subject: 'Your Weekly Update', htmlBody: '<p>Hi ' + name + ',</p><p>Here is your update...</p>' }); sheet.getRange(i + 2, 3).setValue('Sent'); sent++; } catch (e) { sheet.getRange(i + 2, 3).setValue('Error: ' + e.message); } } SpreadsheetApp.flush(); }
Pattern: loop numbered weekly tabs (01-52), read summary cells from each, write aggregated rows into a Summary sheet. Use ss.getSheetByName(tabName) to iterate, ss.insertSheet('Summary') if it doesn't exist, summary.autoResizeColumns() at end, flush() before return.
Always wrap external calls in try/catch. Use muteHttpExceptions: true to handle HTTP errors yourself. Re-throw for dialog error handlers.
javascriptfunction fetchExternalData() { try { const response = UrlFetchApp.fetch('https://api.example.com/data', { headers: { 'Authorization': 'Bearer ' + getApiKey() }, muteHttpExceptions: true }); if (response.getResponseCode() !== 200) throw new Error('API returned ' + response.getResponseCode()); return JSON.parse(response.getContentText()); } catch (e) { Logger.log('Error: ' + e.message); throw e; } }
| Mistake | Fix | |---------|-----| | Dialog can't call function | Remove trailing _ from function name | | Script is slow on large data | Use getValues()/setValues() batch operations | | Changes not visible after dialog | Add SpreadsheetApp.flush() before return | | onEdit can't send email | Use installable trigger via ScriptApp.newTrigger() | | Custom function times out | 30s limit -- simplify or move to regular function | | setTimeout not found | Use Utilities.sleep(ms) (blocking) | | Script exceeds 6 min | Break into chunks, use time-driven trigger for batches | | Auth popup doesn't appear | User must click Advanced > Go to (unsafe) > Allow |
SpreadsheetApp.flush() called before returning from modifying functionssheet.hideRows(), showRows(), isRowHiddenByUser()setBackground(), setFontWeight(), setBorder(), setNumberFormat(), conditional formattingrange.protect(), setUnprotectedRanges(), editor managementgetSheetByName(), looping numbered tabs, copyTo(), insertSheet()onEdit trigger to auto-number column A when column B is editedchat.googleapis.com with JSON payloadOther measured skills in the registry, with their headline benchmark lift.