The workbook started as “a few menu items.” Now it’s a product shared across files, or you need a library, or the bound project’s authorize screen is a mess. You move to standalone — and overnight the time-driven trigger still fires the old project (or nothing fires, or both fire). Orphaned triggers are the migration tax nobody budgets for.
TL;DR
- Copy / recreate the project as standalone (don’t just paste into a new tab and pray)
- Re-point spreadsheet access via
SpreadsheetApp.openById+ Script Property (no more “active is always prod”) - Delete installable triggers on the bound project
- Recreate the same triggers on the standalone project
- Smoke-test the menu item (and one scheduled run) before you call cutover done
Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.
Bound vs standalone (cutover lens)
| Bound | Standalone after move | |
|---|---|---|
| Opens spreadsheet | getActiveSpreadsheet() natural |
Must openById (or attach via library carefully) |
| Menus | onOpen on that file |
Needs a thin bound stub or users open the script’s own UI entry points |
| Installable triggers | Owned by the bound project | Must be recreated on the new project |
| Sharing the logic | Copy-paste hell | One project, many spreadsheets |
If you still need a custom menu inside each spreadsheet, keep a tiny bound onOpen that calls the standalone library — or accept that operators run from the standalone project’s deployments / a web app. Don’t half-migrate.
What not to do
- Don’t leave the old time-based trigger “for rollback” without a calendar reminder — you’ll get double sends
- Don’t assume copying the Sheet copies standalone projects (it doesn’t)
- Don’t hard-code the spreadsheet id in three different files; one Script Property
- Don’t skip a menu smoke-test — authorize screens and missing
openByIdfail there first
Cutover checklist
1. Freeze changes on the bound project (announce a cutover window)
2. Create standalone project; paste/clasp-push the code
3. Set productionSpreadsheetId (Script Property)
4. Replace getActiveSpreadsheet() entry points with openById(property)
5. Deploy / authorize the standalone project once as the runtime user
6. List triggers on the BOUND project → delete installable ones you own
7. Create matching installable triggers on the STANDALONE project
8. Smoke-test: menu (or library menu) + one manual trigger run
9. Archive or gut the bound project so nobody “fixes” the wrong copy
Re-point spreadsheet access
var PROP_SS_ID = 'productionSpreadsheetId';
function getProdSpreadsheet_() {
var id = PropertiesService.getScriptProperties().getProperty(PROP_SS_ID);
if (!id) {
throw new Error('Set Script Property ' + PROP_SS_ID + ' to the spreadsheet id');
}
return SpreadsheetApp.openById(id); // or openByUrlOrId(id) if you ship that snippet
}
function nightlyJob() {
var ss = getProdSpreadsheet_();
rebuildTabs_(ss);
sendDigest_(ss);
}
Bound code that assumed getActive() everywhere needs a deliberate pass. Half-converted helpers are how you silently write to whichever file someone happened to have open.
Delete old installable triggers, then recreate
Simple triggers (onOpen, onEdit without installable setup) live with the bound file and won’t follow you. Installable triggers (time-driven, installable onEdit/onChange, from-form) are project-owned — inventory them.
/** Run WHILE the bound project is still the active editor project. */
function listTriggersForCutover() {
ScriptApp.getProjectTriggers().forEach(function (t) {
Logger.log(
[t.getHandlerFunction(), t.getEventType(), t.getUniqueId()].join(' | ')
);
});
}
/** After you’ve recreated triggers on standalone — run in the BOUND project. */
function deleteAllInstallableTriggers_() {
ScriptApp.getProjectTriggers().forEach(function (t) {
ScriptApp.deleteTrigger(t);
});
}
Recreate on the standalone project via the Triggers UI (safer for a one-time cutover) or programmatically:
function createNightlyTrigger_() {
// Run once from the STANDALONE project
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'nightlyJob') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('nightlyJob').timeBased().atHour(2).everyDays(1).create();
}
Match hour, timezone assumptions, and handler names. A renamed function with an old trigger pointing at the dead name is a silent no-op.
Smoke-test the menu item
If operators lived in the Sheet menu:
// Thin bound stub (optional) — library: StandaloneLib
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Ops')
.addItem('Run nightly now', 'runNightlyFromBound')
.addToUi();
}
function runNightlyFromBound() {
StandaloneLib.nightlyJob(); // or UrlFetch to a web app if you went that route
}
Cutover smoke list:
- Open the spreadsheet as a normal operator → menu appears (if you kept a stub)
- Run the item once → correct spreadsheet id in logs
- Check Triggers page on standalone → expected jobs present
- Check Triggers page on bound → installable jobs gone
- Wait for (or manually fire) one scheduled run → single execution, not double
Failure modes worth naming
- Double triggers — old bound + new standalone both email at 2am
- Orphaned bound project — someone “quick fixes” the wrong code
- Auth under the wrong user — trigger creator ≠ intended runtime identity
- Simple onEdit left behind — installable logic moved; simple trigger still mutates cells
- Library version pin — bound stub calls
HEADwhile you debug; pin a version for production (see the version-pin guide in the queue)
Soft Co-Pilot note
NitroGAS helps you edit and find helpers while you rewire openById; Co-Pilot can draft the property + trigger setup from a cutover sentence. You still delete the old triggers by hand and watch one live run. Free core; Co-Pilot optional.
Closing checklist
- Standalone project authorized as the runtime user
-
productionSpreadsheetIdset; entry points useopenById - Bound installable triggers deleted
- Standalone triggers recreated (handler names match)
- Menu / manual run smoke-tested; no double-send on schedule
Happy Coding!


