apps-script-triggers
Explains Google Apps Script's simple vs installable triggers, their event object shapes, authorization differences, and how to register/manage installable triggers programmatically without duplicating
SKILL.mdskills/apps-script-triggers/SKILL.md
GitHubApps Script Triggers
Simple vs installable
Simple (onOpen, onEdit, onInstall, onSelectionChange) |
Installable (ScriptApp.newTrigger(...)) |
|
|---|---|---|
| Registration | Automatic — GAS calls the global function by name | Explicit — created programmatically or via the Triggers UI |
| Runs as | The user currently viewing the file, with their authorization | The user who created the trigger, with that user's authorization, regardless of who triggers it |
| Can call authorized services | No — no UrlFetchApp, no most advanced services, no services needing OAuth consent |
Yes — full access to whatever the installing user has authorized |
| Typical use | UI-only reactions (build a menu, format a cell) | Anything that calls an external API, sends email, or must run reliably regardless of viewer permissions |
If a simple onEdit needs to call UrlFetchApp or MailApp, it will silently fail or throw a permission error — convert it to an installable trigger instead. This is the most common "trigger works when I test it, breaks for other users" bug: the simple trigger works for you (the owner, already authorized) and fails for a viewer/editor without those grants.
Event object shapes
| Trigger | Event object fields |
|---|---|
onEdit |
{ range, oldValue, value, source, user, authMode } — oldValue/value are only present for single-cell edits with a scalar value; multi-cell pastes omit them (check range.getNumRows()/getNumColumns() instead of assuming value exists) |
onChange |
{ changeType, source, user } — changeType is one of EDIT, INSERT_ROW, INSERT_COLUMN, REMOVE_ROW, REMOVE_COLUMN, INSERT_GRID, REMOVE_GRID, FORMAT, OTHER |
onFormSubmit |
{ values, namedValues, response, source, triggerUid } — namedValues maps question titles to arrays (a checkbox question yields multiple values) |
| Time-driven | No event object — the handler runs with no arguments |
Quotas
Execution time limit: 6 minutes per invocation, for both consumer and Google Workspace accounts as of this writing. Trigger-specific caps (total triggers per user/script, total trigger runtime per day) differ by account type and change without notice — don't trust a number pasted into a skill, including this one. Query the live docs instead of the number above:
../apps-script-services/scripts/check-quotas.sh "Trigger"
../apps-script-services/scripts/check-quotas.sh "runtime"
(The script lives in the apps-script-services skill, which owns the broader quota reference — see that skill if it isn't installed alongside this one.)
Registering installable triggers without duplicating them
ScriptApp.newTrigger(...).create() is not idempotent — running the setup code twice creates two triggers calling the same handler, which then runs twice per event. Always check existing triggers by handler function name before creating:
function ensureEditTrigger(spreadsheetId: string, handlerFunctionName: string): void {
const alreadyExists = ScriptApp.getProjectTriggers().some(
(trigger) => trigger.getHandlerFunction() === handlerFunctionName,
);
if (alreadyExists) return;
ScriptApp.newTrigger(handlerFunctionName)
.forSpreadsheet(spreadsheetId)
.onEdit()
.create();
}
Run this kind of setup once, from an explicit "install" action (a menu item, onInstall, or a setup script) — not from inside the trigger handler itself.
Verification
- Any handler calling an authorized service (
UrlFetchApp,MailApp, advanced services) is registered as an installable trigger, not left as a simple trigger. - Trigger setup code checks
ScriptApp.getProjectTriggers()before creating a new one. -
onEdit/onChangehandlers don't assumeevent.valueexists — they branch onevent.rangedimensions first.