Move a bound script to standalone without orphaning triggers

TL;DR — Cutover checklist for moving a container-bound Apps Script project to standalone: copy the project, re-point SpreadsheetApp.openById, delete old installable triggers, recreate them on the new project, smoke-test the menu.

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 openById fail 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:

  1. Open the spreadsheet as a normal operator → menu appears (if you kept a stub)
  2. Run the item once → correct spreadsheet id in logs
  3. Check Triggers page on standalone → expected jobs present
  4. Check Triggers page on bound → installable jobs gone
  5. 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 HEAD while 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
  • productionSpreadsheetId set; entry points use openById
  • Bound installable triggers deleted
  • Standalone triggers recreated (handler names match)
  • Menu / manual run smoke-tested; no double-send on schedule

Happy Coding!