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'.
.claude/skills/itamarzand88-google-apps-script/SKILL.md| 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 payload| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 14,231 | 13,968 | -2% | 1 | 1 | 0% | 3,046 | 8,230 | +170% | 0 | 0 | — |
case-02 | fail→pass | 17,235 | 19,629 | +14% | 1 | 1 | 0% | 3,545 | 9,655 | +172% | 0 | 0 | — |
case-03 | fail→pass | 23,583 | 19,227 | -18% | 1 | 1 | 0% | 5,641 | 9,898 | +75% | 0 | 0 | — |
case-04 | fail→pass | 15,837 | 15,550 | -2% | 1 | 1 | 0% | 3,620 | 8,511 | +135% | 0 | 0 | — |
case-05 | fail→pass | 10,227 | 17,746 | +74% | 1 | 1 | 0% | 2,063 | 8,970 | +335% | 0 | 0 | — |
case-06 | fail→fail | 12,101 | 15,205 | +26% | 1 | 1 | 0% | 2,476 | 8,209 | +232% | 0 | 0 | — |
case-07 | pass→pass | 9,896 | 7,806 | -21% | 1 | 1 | 0% | 2,021 | 6,837 | +238% | 0 | 0 | — |
case-08 | pass→pass | 10,961 | 23,256 | +112% | 1 | 1 | 0% | 2,510 | 11,297 | +350% | 0 | 0 | — |
case-09 | pass→pass | 8,270 | 11,431 | +38% | 1 | 1 | 0% | 1,772 | 7,482 | +322% | 0 | 0 | — |
case-10 | pass→pass | 9,328 | 19,376 | +108% | 1 | 1 | 0% | 2,137 | 10,371 | +385% | 0 | 0 | — |
case-11 | fail→pass | 8,290 | 8,876 | +7% | 1 | 1 | 0% | 2,123 | 7,344 | +246% | 0 | 0 | — |
case-12 | pass→pass | 10,060 | 8,158 | -19% | 1 | 1 | 0% | 2,100 | 6,872 | +227% | 0 | 0 | — |
case-13 | pass→pass | 3,763 | 5,138 | +37% | 1 | 1 | 0% | 876 | 6,259 | +614% | 0 | 0 | — |
case-14 | pass→pass | 8,538 | 15,122 | +77% | 1 | 1 | 0% | 1,972 | 8,563 | +334% | 0 | 0 | — |
case-15 | pass→pass | 6,169 | 9,270 | +50% | 1 | 1 | 0% | 1,548 | 7,420 | +379% | 0 | 0 | — |
case-16 | pass→pass | 3,807 | 4,569 | +20% | 1 | 1 | 0% | 889 | 6,221 | +600% | 0 | 0 | — |
case-17 | pass→pass | 5,221 | 6,008 | +15% | 1 | 1 | 0% | 1,230 | 6,464 | +426% | 0 | 0 | — |
case-18 | pass→pass | 6,363 | 5,927 | -7% | 1 | 1 | 0% | 1,623 | 6,500 | +300% | 0 | 0 | — |
case-19 | fail→fail | 9,117 | 10,057 | +10% | 1 | 1 | 0% | 2,207 | 7,580 | +243% | 0 | 0 | — |
case-20 | pass→pass | 15,462 | 11,417 | -26% | 1 | 1 | 0% | 3,520 | 8,064 | +129% | 0 | 0 | — |
case-21 | pass→pass | 10,652 | 8,288 | -22% | 1 | 1 | 0% | 2,494 | 7,096 | +185% | 0 | 0 | — |
case-22 | pass→pass | 16,755 | 11,406 | -32% | 1 | 1 | 0% | 4,594 | 8,263 | +80% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.