Skip to content
Skip to chapter

Chapter 24

User interfaces

Build a personal Workspace add-on

Put a useful checklist in the Drive side panel with CardService, validated actions, and per-user saved state without reading Drive files.

By the end

Install an approved development add-on, save and reload a personal checklist, and explain how homepage triggers, action events, native cards, and user-scoped state work together.

Chapter navigation

Keep a small handoff checklist beside your work in Google Drive. Check that you have identified the next owner, considered the requested access, and identified the next step. Add a short note, save it, and return later to the same checklist.

This example is a Google Workspace add-on with a native card interface. Drive provides the side panel; Apps Script builds the card and handles its buttons. The add-on stores one checklist for your Google account in this script project without reading Drive files, changing sharing, or sending messages.

Choose the right kind of interface

An add-on is an application installed into a Workspace host rather than a script bound to a single file.

InterfaceWhere the UI livesWhat starts the interaction
A bound script's menu, sidebar, or dialogA particular editor file; HTML Service can provide custom browser UIA menu callback, followed by google.script.run for HTML-to-server calls
An Editor add-onAn editor such as Docs, Sheets, Slides, or Forms; menus and HTML dialogs or sidebarsThe Editor add-on installation/enabling lifecycle and editor callbacks such as onOpen
This Google Workspace add-onDrive's add-on side panel, rendered from CardService widgetsA manifest homepage trigger, then named server callbacks attached to card actions
An Apps Script web appA separately deployed URLHTTP requests handled by doGet or doPost

Workspace add-ons can extend several supported hosts through one project when each host is enabled in appsscript.json. An add-on built with cards inside Sheets is a Workspace add-on rather than an Editor add-on; the two add-on types have different UI and authorization contracts. See Google's add-on type comparison.

Unlike an HTML Service sidebar, CardService cards have no browser DOM, CSS stylesheet, or google.script.run bridge. You construct a Card on the Apps Script server and return it to the host, which renders the widgets and dispatches button actions to named server callbacks. See the CardService reference.

Decide what belongs to the user

This checklist stays with your Google account as you move between folders or files in Drive.

PropertiesService.getUserProperties() gives the current user a property store scoped to this script project, and Workspace add-on callbacks run as the user at the keyboard. Together, those rules make the checklist per user and per script project, as documented in Google's execution-identity table and PropertiesService reference.

Two browser tabs signed into the same Google account read and write the same record, while another user of the same add-on has an isolated user store. Use User Properties rather than Script Properties, which are shared across every user of the script.

The three checkboxes and the note remain a local draft in the card until you click Save checklist. The progress text at the top describes the saved record, and Reload saved discards unsaved edits and reloads the stored record.

Create the development project

  1. Open script.google.com and create a new standalone project named Handoff checklist.

  2. Replace Code.gs with the source file below.

  3. Open Project Settings, enable Show "appsscript.json" manifest file in editor, replace appsscript.json with the manifest below, and save both files.

  4. Keep the project's default Google Cloud project for this unpublished test installation. A standard Google Cloud project is only needed later for versioned add-on deployments, OAuth verification, or Marketplace publication; see the Cloud project guide and deployment guide.

Code.gs
const CHECKLIST_KEY = 'HANDOFF_CHECKLIST_V1';
const NOTE_LIMIT = 120;
const RECORD_BYTE_LIMIT = 2048;
const CHECKLIST_STEPS = [
  { id: 'owner', label: 'Confirm the next owner' },
  { id: 'access', label: 'Check requested access' },
  { id: 'next', label: 'Identify the next step' },
];

/**
 * @typedef {{version: number, revision: number, note: string, checked: string[]}} ChecklistState_
 */
/**
 * @typedef {{note: string, checked: string[]}} ChecklistDraft_
 */
/**
 * @typedef {{hostApp: string, parameters: Record<string, string>, formInputs?: Record<string, unknown>}} ChecklistEvent_
 */

class ChecklistError_ extends Error {
  /** @param {string} code */
  constructor(code) {
    super(code);
    this.code = code;
  }
}

// A manifest homepage trigger returns cards, not an ActionResponse or HTML.
/**
 * @param {unknown} e
 * @returns {CardService.Card[]}
 */
function onHomepage(e) {
  try {
    requireDriveEvent_(e, false);
    const state = withUserState_((_properties, current) => current);
    return [buildChecklistCard_(state)];
  } catch (error) {
    return [buildErrorCard_(messageFor_(error))];
  }
}

// Save replaces this user's one checklist, never a Drive file or its sharing.
/**
 * @param {unknown} e
 * @returns {CardService.ActionResponse}
 */
function saveChecklist(e) {
  try {
    const common = requireAction_(e, 'save');
    const revision = readRevision_(common.parameters.revision);
    const draft = readDraft_(common.formInputs);
    const result = withUserState_((properties, current) => {
      // A repeated submission can already match, even after a lost response.
      if (sameChecklist_(current, draft)) {
        return { state: current, changed: false };
      }
      if (revision !== current.revision) {
        throw new ChecklistError_('STALE');
      }
      if (current.revision === Number.MAX_SAFE_INTEGER) {
        throw new ChecklistError_('LIMIT');
      }
      const next = {
        version: 1,
        revision: current.revision + 1,
        note: draft.note,
        checked: draft.checked,
      };
      const serialized = JSON.stringify(next);
      if (utf8Length_(serialized) > RECORD_BYTE_LIMIT) {
        throw new ChecklistError_('LIMIT');
      }
      properties.setProperty(CHECKLIST_KEY, serialized);
      return { state: next, changed: true };
    });
    return replaceCard_(
      result.state,
      result.changed ? 'Checklist saved.' : 'Already current; nothing written.',
      result.changed,
    );
  } catch (error) {
    // Notification only: do not replace the card or erase an unsaved draft.
    // An unexpected failure can follow a completed property write.
    return notify_(messageFor_(error));
  }
}

// Deliberately discard the displayed draft and read the current saved record.
/**
 * @param {unknown} e
 * @returns {CardService.ActionResponse}
 */
function reloadChecklist(e) {
  try {
    requireAction_(e, 'reload');
    const state = withUserState_((_properties, current) => current);
    return replaceCard_(state, 'Saved checklist loaded.', false);
  } catch (error) {
    return notify_(messageFor_(error));
  }
}

/**
 * @param {unknown} e
 * @param {boolean} requireParameters
 * @returns {ChecklistEvent_}
 */
function requireDriveEvent_(e, requireParameters) {
  if (!isObject_(e) || !isObject_(e.commonEventObject)) {
    throw new ChecklistError_('EVENT');
  }
  const common = e.commonEventObject;
  // Google omits commonEventObject.parameters from homepage events; button
  // actions always carry the parameters their own card attached.
  const parameters = common.parameters === undefined && !requireParameters ? {} : common.parameters;
  if (common.hostApp !== 'DRIVE' || !isStringRecord_(parameters) ||
      (common.formInputs !== undefined && !isObject_(common.formInputs))) {
    throw new ChecklistError_('EVENT');
  }
  return {
    hostApp: 'DRIVE',
    parameters: parameters,
    ...(common.formInputs === undefined ? {} : { formInputs: common.formInputs }),
  };
}

/**
 * @param {unknown} e
 * @param {string} operation
 * @returns {ChecklistEvent_}
 */
function requireAction_(e, operation) {
  const common = requireDriveEvent_(e, true);
  const keys = operation === 'save' ? ['operation', 'revision'] : ['operation'];
  if (!exactKeys_(common.parameters, keys) ||
      common.parameters.operation !== operation) {
    throw new ChecklistError_('EVENT');
  }
  return common;
}

/**
 * @param {unknown} text
 * @returns {number}
 */
function readRevision_(text) {
  if (typeof text !== 'string' || !/^(0|[1-9][0-9]{0,15})$/.test(text)) {
    throw new ChecklistError_('EVENT');
  }
  const revision = Number(text);
  if (!Number.isSafeInteger(revision)) {
    throw new ChecklistError_('EVENT');
  }
  return revision;
}

/**
 * @param {unknown} formInputs
 * @returns {ChecklistDraft_}
 */
function readDraft_(formInputs) {
  // Empty widgets can be absent. A present malformed widget is not empty.
  const inputs = formInputs === undefined ? {} : formInputs;
  if (!isObject_(inputs) ||
      Object.keys(inputs).some((key) => key !== 'note' && key !== 'checked')) {
    throw new ChecklistError_('EVENT');
  }
  const notes = stringValues_(inputs, 'note', 1);
  const note = notes.length === 0 ? '' : notes[0];
  if (!validNote_(note)) {
    throw new ChecklistError_('NOTE');
  }
  const checked = normalizeChecked_(stringValues_(inputs, 'checked', 3), 'STEPS');
  return { note, checked };
}

/**
 * @param {Record<string, unknown>} inputs
 * @param {string} name
 * @param {number} maximum
 * @returns {string[]}
 */
function stringValues_(inputs, name, maximum) {
  if (!Object.prototype.hasOwnProperty.call(inputs, name)) return [];
  const widget = inputs[name];
  if (!isObject_(widget) || !isObject_(widget.stringInputs) ||
      !Array.isArray(widget.stringInputs.value)) {
    throw new ChecklistError_('EVENT');
  }
  const values = widget.stringInputs.value;
  if (values.length > maximum || values.some((value) => typeof value !== 'string')) {
    throw new ChecklistError_('EVENT');
  }
  return values;
}

/**
 * @param {unknown} note
 * @returns {boolean}
 */
function validNote_(note) {
  return typeof note === 'string' && note.length <= NOTE_LIMIT &&
    !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(note);
}

/**
 * @param {string[]} values
 * @param {string} errorCode
 * @returns {string[]}
 */
function normalizeChecked_(values, errorCode) {
  if (!Array.isArray(values) || values.length > CHECKLIST_STEPS.length ||
      values.some((value, index) =>
        typeof value !== 'string' ||
        !CHECKLIST_STEPS.some((step) => step.id === value) ||
        values.indexOf(value) !== index)) {
    throw new ChecklistError_(errorCode);
  }
  // Widget selection order must not turn the same choices into a new save.
  return CHECKLIST_STEPS.filter((step) => values.includes(step.id)).map((step) => step.id);
}

/**
 * @template T
 * @param {(properties: PropertiesService.Properties, current: ChecklistState_) => T} work
 * @returns {T}
 */
function withUserState_(work) {
  const lock = LockService.getUserLock();
  if (!lock.tryLock(1000)) throw new ChecklistError_('BUSY');
  try {
    const properties = PropertiesService.getUserProperties();
    return work(properties, readState_(properties));
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {PropertiesService.Properties} properties
 * @returns {ChecklistState_}
 */
function readState_(properties) {
  const raw = properties.getProperty(CHECKLIST_KEY);
  if (raw === null) {
    // First render does not create a record.
    return { version: 1, revision: 0, note: '', checked: [] };
  }
  if (typeof raw !== 'string' || raw.length > RECORD_BYTE_LIMIT ||
      utf8Length_(raw) > RECORD_BYTE_LIMIT) {
    throw new ChecklistError_('STATE');
  }
  let state;
  try {
    state = JSON.parse(raw);
  } catch (_error) {
    throw new ChecklistError_('STATE');
  }
  if (!exactKeys_(state, ['version', 'revision', 'note', 'checked']) ||
      state.version !== 1 || !Number.isSafeInteger(state.revision) ||
      state.revision < 1 || !validNote_(state.note)) {
    throw new ChecklistError_('STATE');
  }
  return {
    version: 1,
    revision: state.revision,
    note: state.note,
    checked: normalizeChecked_(state.checked, 'STATE'),
  };
}

/**
 * @param {ChecklistState_} state
 * @param {ChecklistDraft_} draft
 * @returns {boolean}
 */
function sameChecklist_(state, draft) {
  return state.note === draft.note &&
    state.checked.length === draft.checked.length &&
    state.checked.every((value, index) => value === draft.checked[index]);
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, unknown>}
 */
function isObject_(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, string>}
 */
function isStringRecord_(value) {
  return isObject_(value) && Object.values(value).every((entry) => typeof entry === 'string');
}

/**
 * @param {unknown} value
 * @param {string[]} keys
 * @returns {boolean}
 */
function exactKeys_(value, keys) {
  return isObject_(value) && Object.keys(value).length === keys.length &&
    keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
}

/**
 * @param {string} text
 * @returns {number}
 */
function utf8Length_(text) {
  return Utilities.newBlob(text).getBytes().length;
}

/**
 * @param {string} functionName
 * @param {Record<string, string>} parameters
 * @returns {CardService.Action}
 */
function cardAction_(functionName, parameters) {
  return CardService.newAction()
    .setFunctionName(functionName)
    .setParameters(parameters)
    .setLoadIndicator(CardService.LoadIndicator.SPINNER)
    .setPersistValues(false);
}

/**
 * CardService's generated declarations omit the shared widget base type for
 * input widgets, although the runtime accepts them in a card section.
 *
 * @param {CardService.SelectionInput | CardService.TextInput} widget
 * @returns {CardService.Widget}
 */
function inputWidget_(widget) {
  return /** @type {CardService.Widget} */ (/** @type {unknown} */ (widget));
}

function reloadButton_() {
  return CardService.newTextButton()
    .setText('Reload saved')
    .setOnClickAction(cardAction_('reloadChecklist', { operation: 'reload' }));
}

/**
 * @param {ChecklistState_} state
 * @returns {CardService.Card}
 */
function buildChecklistCard_(state) {
  const progress = state.revision === 0
    ? 'No saved checklist yet. 0 of 3 checked.'
    : 'Saved progress: ' + state.checked.length + ' of 3 checked. Revision ' + state.revision + '.';
  const choices = CardService.newSelectionInput()
    .setType(CardService.SelectionInputType.CHECK_BOX)
    .setFieldName('checked')
    .setTitle('Completed items');
  for (const step of CHECKLIST_STEPS) {
    choices.addItem(step.label, step.id, state.checked.includes(step.id));
  }
  const note = CardService.newTextInput()
    .setFieldName('note')
    .setTitle('Next-step note')
    .setHint('Optional; one line, up to 120 characters. Use fictional data.')
    .setMultiline(false)
    // A widget value, not interpolated HTML or a formatted TextParagraph.
    .setValue(state.note);
  const save = CardService.newTextButton()
    .setText('Save checklist')
    .setOnClickAction(cardAction_('saveChecklist', {
      operation: 'save',
      revision: String(state.revision),
    }));
  const buttons = CardService.newButtonSet().addButton(save).addButton(reloadButton_());
  const section = CardService.newCardSection()
    .addWidget(CardService.newTextParagraph().setText(progress))
    .addWidget(CardService.newTextParagraph().setText(
      'One personal checklist, not linked to a selected file. No files or permissions are checked.',
    ))
    .addWidget(inputWidget_(choices))
    .addWidget(inputWidget_(note))
    .addWidget(CardService.newTextParagraph().setText(
      'Edits are not saved until Save checklist. Reload saved discards unsaved edits.',
    ))
    .addWidget(buttons);
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('Handoff checklist'))
    .addSection(section)
    .build();
}

/**
 * @param {string} message
 * @returns {CardService.Card}
 */
function buildErrorCard_(message) {
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('Checklist unavailable'))
    .addSection(CardService.newCardSection()
      .addWidget(CardService.newTextParagraph().setText(message))
      .addWidget(CardService.newButtonSet().addButton(reloadButton_())))
    .build();
}

/**
 * @param {ChecklistState_} state
 * @param {string} message
 * @param {boolean} changed
 * @returns {CardService.ActionResponse}
 */
function replaceCard_(state, message, changed) {
  return CardService.newActionResponseBuilder()
    .setNavigation(CardService.newNavigation().updateCard(buildChecklistCard_(state)))
    .setNotification(CardService.newNotification().setText(message))
    .setStateChanged(changed)
    .build();
}

/**
 * @param {string} message
 * @returns {CardService.ActionResponse}
 */
function notify_(message) {
  return CardService.newActionResponseBuilder()
    .setNotification(CardService.newNotification().setText(message))
    .build();
}

/**
 * @param {unknown} error
 * @returns {string}
 */
function messageFor_(error) {
  if (error instanceof ChecklistError_) {
    switch (error.code) {
      case 'EVENT':
        return 'Invalid card input. Reopen the installed add-on in Drive; nothing was written.';
      case 'NOTE':
        return 'Note not saved. Use one line of at most 120 characters without control characters.';
      case 'STEPS':
        return 'Checklist not saved. Choose only the three displayed items, without duplicates.';
      case 'STALE':
        return 'Not saved: the stored checklist changed. Keep your draft elsewhere, then Reload saved.';
      case 'BUSY':
        return 'Another checklist operation is busy. Wait, then try again; this action wrote nothing.';
      case 'STATE':
        return 'Stored checklist is invalid. It was not reset. Preserve it and ask the project owner for help.';
      case 'LIMIT':
        return 'Checklist storage limit reached. Nothing was written; ask the project owner for help.';
    }
  }
  return 'Could not confirm the result. Keep your draft elsewhere, then Reload saved before saving again.';
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "addOns": {
    "common": {
      "name": "Handoff checklist",
      "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/check_circle_black_48dp.png",
      "homepageTrigger": {
        "runFunction": "onHomepage",
        "enabled": true
      }
    },
    "drive": {}
  }
}

Download appsscript.json

Read the manifest as the host contract

addOns.common.name and logoUrl identify the add-on in the Workspace toolbar. Before publishing your own add-on, replace the sample icon URL with an asset you control.

addOns.common.homepageTrigger.runFunction names onHomepage with enabled: true. The empty addOns.drive object enables the add-on in Drive and inherits the common homepage without declaring a file-selection trigger. Omitting addOns.drive leaves the add-on disabled in Drive. See the Common manifest resource, Drive resource, and Drive homepage instructions.

The manifest sets oauthScopes to []. Displaying a CardService homepage in Drive and using PropertiesService.getUserProperties(), LockService.getUserLock(), and Utilities.newBlob require no Drive, Gmail, or external-request OAuth scopes. See the Workspace add-on scope guide.

Install the unpublished add-on

Install the development add-on into your Google account:

  1. In the Apps Script editor, select Deploy > Test deployments.

  2. Select Install, then Done.

  3. Open Google Drive (drive.google.com) in the same account and refresh the tab.

  4. Expand the right-side panel and open Handoff checklist using its check-circle icon.

Google's unpublished add-on testing instructions use Test deployments > Install, which requires edit access to the script project (and requires additional testers to belong to the script owner's domain).

On a fresh user store, the card displays No saved checklist yet. 0 of 3 checked. with three unchecked boxes and an empty note. Rendering this initial card does not write a property.

Do not run onHomepage with the editor's Run button to test the UI: the editor passes no host event object, so onHomepage returns an invalid-event error card without rendering it in Drive.

Follow the homepage and action lifecycle

The homepage is a non-contextual card that opens in Drive without requiring a selected file. Its event object provides host metadata (commonEventObject.hostApp) rather than selected Drive items or form inputs; see the homepage guide.

Opening the add-on runs four steps:

  1. Drive invokes onHomepage(e).

  2. requireDriveEvent_ verifies that e.commonEventObject.hostApp is 'DRIVE'.

  3. The handler reads the user's stored checklist under the user lock and builds the card widgets.

  4. onHomepage returns [card] (or an error card if the stored property is malformed).

A card consists of sections and widgets: a SelectionInput (SelectionInputType.CHECK_BOX) for the three items and a TextInput (fieldName: 'note') for the note. Adding a widget to a section copies its current builder state, so configure each widget before calling addWidget; see the Workspace add-ons building guide.

The Save checklist button attaches an Action via setOnClickAction, setting the callback name 'saveChecklist' and string parameters operation and revision. Clicking the button sends the current widget values and action parameters to saveChecklist(e). Because no onChange action is attached to the inputs, ticking boxes or typing in the note field does not call the server until you click a button; see add-on actions.

Return the response the host expects

While onHomepage returns a Card (or array of cards), a button callback that updates the UI returns an ActionResponse.

After saving, replaceCard_ builds an ActionResponse with Navigation.updateCard(...) and a notification. updateCard replaces the current card in place rather than pushing a duplicate onto the navigation stack (pushCard and popCard manage multi-card drilldowns; see the navigation guide).

Both buttons configure LoadIndicator.SPINNER and setPersistValues(false). When updateCard replaces the card, setPersistValues(false) tells the host to display the server's newly returned widget values instead of preserving the client's draft inputs, which is what allows Reload saved to reset an unsaved draft. On a validation error, the callback returns a notification without calling updateCard, leaving the user's draft intact to edit. See the Action reference.

A save that writes new data calls setStateChanged(true), while a no-op save or reload calls setStateChanged(false); see ActionResponseBuilder.

Do not confuse manifest triggers with editor triggers

Workspace add-ons declare their homepage and contextual triggers in appsscript.json rather than installing them with ScriptApp.newTrigger or using simple triggers like onOpen; see the manifest trigger guide. To respond to Drive file selections in a future add-on, you would declare onItemsSelectedTrigger and the required Drive metadata scope in appsscript.json.

Validate the current event, not an older Gmail example

The handlers read e.commonEventObject.parameters and e.commonEventObject.formInputs rather than the deprecated top-level e.parameters, e.formInput, and e.formInputs fields from older Gmail add-ons; see the Workspace add-on event-object reference.

saveChecklist validates five inputs:

FieldRequired meaning in this project
commonEventObject.hostApp'DRIVE'
commonEventObject.parameters.operation'save'
commonEventObject.parameters.revisionNon-negative integer string
commonEventObject.formInputs.note.stringInputs.valueZero or one string; an omitted widget is treated as ""
commonEventObject.formInputs.checked.stringInputs.valueUp to three unique strings from 'owner', 'access', and 'next'

In commonEventObject.formInputs, even a single-line TextInput wraps its string in stringInputs.value as a one-element array, while a CHECK_BOX group places all selected item values in that array; see the TextInput and SelectionInput references.

The validator rejects unexpected field names, extra parameters, duplicate checkbox IDs, unknown item IDs, notes longer than 120 UTF-16 code units (note.length), and control characters or line breaks before reading or writing UserProperties.

The note value is passed to TextInput.setValue(note), which sets the plain-text field value without parsing HTML; characters such as <draft> and & display literally.

Save one bounded record

The HANDOFF_CHECKLIST_V1 user property stores a single JSON record capped at 2,048 UTF-8 bytes (measured with Utilities.newBlob(text).getBytes().length; see the Properties and Utilities references):

Source
{
  "version": 1,
  "revision": 1,
  "note": "REQ-001: check access with the next owner.",
  "checked": ["owner", "access"]
}

When no property exists yet, the card renders with revision 0 without writing to UserProperties. If an existing property is malformed or exceeds 2,048 bytes, the add-on preserves the stored value and displays an error card rather than overwriting it with defaults.

Keep two cards from overwriting each other

Because a user can open Drive in two tabs at once, every read and write runs inside withUserState_, which acquires LockService.getUserLock() via tryLock(1000) and releases it in finally; see the LockService and Lock references.

Under the user lock, saveChecklist compares the submitted values and revision against the stored record:

  • Unchanged values: if the note and normalized checked list match the stored record, it returns the current card without writing or incrementing revision.

  • Stale revision: if the values differ and the card's revision does not match the stored revision, it returns a notification without overwriting the newer record or clearing the user's draft.

  • Matching revision with changes: it increments revision, writes HANDOFF_CHECKLIST_V1 once, and updates the card.

Limit the work in each callback

A homepage load or reload performs one getProperty call; a save performs one getProperty and at most one setProperty. Google's quotas table limits Workspace add-on callback executions to 30 seconds and individual property values to 9 KB, so keep card callbacks fast and bounded.

Understand installation and authorization separately

Installing an add-on adds it to the Workspace host toolbar; OAuth authorization grants the scopes declared in appsscript.json. Because this project declares "oauthScopes": [], it requests no access to Drive files or other Google data. See Workspace add-on scopes and Apps Script authorization.

Note that Editor add-ons (for Docs, Sheets, Slides, and Forms) use a different onOpen(e) and e.authMode (AuthMode.NONE, AuthMode.LIMITED, AuthMode.FULL) lifecycle documented in the Editor add-on authorization guide; Workspace add-ons use the commonEventObject contract instead.

Predict, change, observe, and restore

Start with a fresh revision-0 card in Drive:

Save the draft and repeat it

  1. Enter REQ-001: check access with the next owner. in Next-step note, and check Confirm the next owner and Check requested access.

  2. Confirm the top progress line still reads 0 of 3 checked before saving.

  3. Click Save checklist. The card updates to Saved progress: 2 of 3 checked with revision 1.

  4. Click Save checklist again without editing. The notification reports Already current; nothing written. and the revision stays 1.

  5. Check Identify the next step and click Save checklist. The card shows 3 of 3 checked and revision 2.

Reject an invalid note without losing the saved one

  1. Replace the note with 0123456789 repeated 13 times (130 characters, exceeding the 120-character limit).

  2. Click Save checklist. The server rejects the note with a notification, leaves the stored revision at 2, and keeps the 130-character draft in the input field.

  3. Click Reload saved to discard the invalid draft and restore the saved note, three checked boxes, and revision 2.

Keep text as text

Change the note to Review <draft> & confirm access. and uncheck Identify the next step, then click Save checklist. Confirm the angle brackets and ampersand remain literal text and the revision increments to 3. Restore the original note, recheck the third box, and save (revision 4).

See why the revision travels with the card

  1. Open the add-on in two Drive tabs signed into the same account and click Reload saved in both so both cards carry the same revision.

  2. In the first tab, change the note to REQ-001: ask for a named next owner. and click Save checklist.

  3. In the second tab (without reloading), change the note to REQ-001: compare before replacing the note. and click Save checklist.

  4. The second tab shows a stale-card notification without overwriting the first tab's save. Click Reload saved in the second tab to load the first tab's saved state.

  5. Restore the original REQ-001: check access with the next owner. note and three checked items, then save.

Change a label without changing stored identity

In CHECKLIST_STEPS in Code.gs, change the label 'Check requested access' to 'Check access with the next owner' while keeping id: 'access'. Save Code.gs and click Reload saved in the Drive card. Because the development installation runs the head deployment (see head and versioned deployments) and the stored record keys items by id, the new label appears with the saved selection intact. Restore the original label in Code.gs, save, and reload the card.

Recover from an error without inventing success

SymptomMeaning and next action
No icon after installationConfirm you are in the same Google account, refresh Drive, open the right-side panel, and check Test deployments in this project.
Invalid card inputThe callback did not receive a Drive card event. Use the installed Drive side panel rather than clicking Run in the script editor.
Note or checklist validation notificationShorten or fix the draft note, or click Reload saved to revert to the stored record.
Another checklist operation is busyAnother execution holds the user lock. Wait a moment and try again.
The stored checklist changedAnother tab saved a newer revision. Copy your draft if needed, click Reload saved, and apply your changes to the current revision.
Stored checklist is invalidHANDOFF_CHECKLIST_V1 contains malformed JSON or an unsupported version. Inspect the user property rather than overwriting it blindly.
Could not confirm the result, timeout, or missing responseThe write may have succeeded before the response dropped. Click Reload saved to inspect the stored revision before saving again.

Stop the experiment and separate distribution work

To clear the checklist before uninstalling, clear the note, uncheck the three boxes, and click Save checklist.

To remove the development installation, open the Apps Script editor, choose Deploy > Test deployments > Uninstall > Done, and refresh Drive; see Google's uninstall instructions. Uninstalling the test deployment removes the icon from Drive without deleting UserProperties or the script project; if you authorized any OAuth connections, you can revoke them from your Google Account security settings as described in the authorization guide.

A Test deployments installation runs the project's mutable head code for editors. Releasing an add-on to other users requires a standard Google Cloud project, a versioned deployment, OAuth consent configuration, and a Google Workspace Marketplace listing, as covered in the add-on building and publication overview and the next chapter, Install and release a Workspace add-on.

Project files

Complete source files for this chapter’s examples.

Keep a personal handoff checklist in a Drive add-on
Code.gs
const CHECKLIST_KEY = 'HANDOFF_CHECKLIST_V1';
const NOTE_LIMIT = 120;
const RECORD_BYTE_LIMIT = 2048;
const CHECKLIST_STEPS = [
  { id: 'owner', label: 'Confirm the next owner' },
  { id: 'access', label: 'Check requested access' },
  { id: 'next', label: 'Identify the next step' },
];

/**
 * @typedef {{version: number, revision: number, note: string, checked: string[]}} ChecklistState_
 */
/**
 * @typedef {{note: string, checked: string[]}} ChecklistDraft_
 */
/**
 * @typedef {{hostApp: string, parameters: Record<string, string>, formInputs?: Record<string, unknown>}} ChecklistEvent_
 */

class ChecklistError_ extends Error {
  /** @param {string} code */
  constructor(code) {
    super(code);
    this.code = code;
  }
}

// A manifest homepage trigger returns cards, not an ActionResponse or HTML.
/**
 * @param {unknown} e
 * @returns {CardService.Card[]}
 */
function onHomepage(e) {
  try {
    requireDriveEvent_(e, false);
    const state = withUserState_((_properties, current) => current);
    return [buildChecklistCard_(state)];
  } catch (error) {
    return [buildErrorCard_(messageFor_(error))];
  }
}

// Save replaces this user's one checklist, never a Drive file or its sharing.
/**
 * @param {unknown} e
 * @returns {CardService.ActionResponse}
 */
function saveChecklist(e) {
  try {
    const common = requireAction_(e, 'save');
    const revision = readRevision_(common.parameters.revision);
    const draft = readDraft_(common.formInputs);
    const result = withUserState_((properties, current) => {
      // A repeated submission can already match, even after a lost response.
      if (sameChecklist_(current, draft)) {
        return { state: current, changed: false };
      }
      if (revision !== current.revision) {
        throw new ChecklistError_('STALE');
      }
      if (current.revision === Number.MAX_SAFE_INTEGER) {
        throw new ChecklistError_('LIMIT');
      }
      const next = {
        version: 1,
        revision: current.revision + 1,
        note: draft.note,
        checked: draft.checked,
      };
      const serialized = JSON.stringify(next);
      if (utf8Length_(serialized) > RECORD_BYTE_LIMIT) {
        throw new ChecklistError_('LIMIT');
      }
      properties.setProperty(CHECKLIST_KEY, serialized);
      return { state: next, changed: true };
    });
    return replaceCard_(
      result.state,
      result.changed ? 'Checklist saved.' : 'Already current; nothing written.',
      result.changed,
    );
  } catch (error) {
    // Notification only: do not replace the card or erase an unsaved draft.
    // An unexpected failure can follow a completed property write.
    return notify_(messageFor_(error));
  }
}

// Deliberately discard the displayed draft and read the current saved record.
/**
 * @param {unknown} e
 * @returns {CardService.ActionResponse}
 */
function reloadChecklist(e) {
  try {
    requireAction_(e, 'reload');
    const state = withUserState_((_properties, current) => current);
    return replaceCard_(state, 'Saved checklist loaded.', false);
  } catch (error) {
    return notify_(messageFor_(error));
  }
}

/**
 * @param {unknown} e
 * @param {boolean} requireParameters
 * @returns {ChecklistEvent_}
 */
function requireDriveEvent_(e, requireParameters) {
  if (!isObject_(e) || !isObject_(e.commonEventObject)) {
    throw new ChecklistError_('EVENT');
  }
  const common = e.commonEventObject;
  // Google omits commonEventObject.parameters from homepage events; button
  // actions always carry the parameters their own card attached.
  const parameters = common.parameters === undefined && !requireParameters ? {} : common.parameters;
  if (common.hostApp !== 'DRIVE' || !isStringRecord_(parameters) ||
      (common.formInputs !== undefined && !isObject_(common.formInputs))) {
    throw new ChecklistError_('EVENT');
  }
  return {
    hostApp: 'DRIVE',
    parameters: parameters,
    ...(common.formInputs === undefined ? {} : { formInputs: common.formInputs }),
  };
}

/**
 * @param {unknown} e
 * @param {string} operation
 * @returns {ChecklistEvent_}
 */
function requireAction_(e, operation) {
  const common = requireDriveEvent_(e, true);
  const keys = operation === 'save' ? ['operation', 'revision'] : ['operation'];
  if (!exactKeys_(common.parameters, keys) ||
      common.parameters.operation !== operation) {
    throw new ChecklistError_('EVENT');
  }
  return common;
}

/**
 * @param {unknown} text
 * @returns {number}
 */
function readRevision_(text) {
  if (typeof text !== 'string' || !/^(0|[1-9][0-9]{0,15})$/.test(text)) {
    throw new ChecklistError_('EVENT');
  }
  const revision = Number(text);
  if (!Number.isSafeInteger(revision)) {
    throw new ChecklistError_('EVENT');
  }
  return revision;
}

/**
 * @param {unknown} formInputs
 * @returns {ChecklistDraft_}
 */
function readDraft_(formInputs) {
  // Empty widgets can be absent. A present malformed widget is not empty.
  const inputs = formInputs === undefined ? {} : formInputs;
  if (!isObject_(inputs) ||
      Object.keys(inputs).some((key) => key !== 'note' && key !== 'checked')) {
    throw new ChecklistError_('EVENT');
  }
  const notes = stringValues_(inputs, 'note', 1);
  const note = notes.length === 0 ? '' : notes[0];
  if (!validNote_(note)) {
    throw new ChecklistError_('NOTE');
  }
  const checked = normalizeChecked_(stringValues_(inputs, 'checked', 3), 'STEPS');
  return { note, checked };
}

/**
 * @param {Record<string, unknown>} inputs
 * @param {string} name
 * @param {number} maximum
 * @returns {string[]}
 */
function stringValues_(inputs, name, maximum) {
  if (!Object.prototype.hasOwnProperty.call(inputs, name)) return [];
  const widget = inputs[name];
  if (!isObject_(widget) || !isObject_(widget.stringInputs) ||
      !Array.isArray(widget.stringInputs.value)) {
    throw new ChecklistError_('EVENT');
  }
  const values = widget.stringInputs.value;
  if (values.length > maximum || values.some((value) => typeof value !== 'string')) {
    throw new ChecklistError_('EVENT');
  }
  return values;
}

/**
 * @param {unknown} note
 * @returns {boolean}
 */
function validNote_(note) {
  return typeof note === 'string' && note.length <= NOTE_LIMIT &&
    !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(note);
}

/**
 * @param {string[]} values
 * @param {string} errorCode
 * @returns {string[]}
 */
function normalizeChecked_(values, errorCode) {
  if (!Array.isArray(values) || values.length > CHECKLIST_STEPS.length ||
      values.some((value, index) =>
        typeof value !== 'string' ||
        !CHECKLIST_STEPS.some((step) => step.id === value) ||
        values.indexOf(value) !== index)) {
    throw new ChecklistError_(errorCode);
  }
  // Widget selection order must not turn the same choices into a new save.
  return CHECKLIST_STEPS.filter((step) => values.includes(step.id)).map((step) => step.id);
}

/**
 * @template T
 * @param {(properties: PropertiesService.Properties, current: ChecklistState_) => T} work
 * @returns {T}
 */
function withUserState_(work) {
  const lock = LockService.getUserLock();
  if (!lock.tryLock(1000)) throw new ChecklistError_('BUSY');
  try {
    const properties = PropertiesService.getUserProperties();
    return work(properties, readState_(properties));
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {PropertiesService.Properties} properties
 * @returns {ChecklistState_}
 */
function readState_(properties) {
  const raw = properties.getProperty(CHECKLIST_KEY);
  if (raw === null) {
    // First render does not create a record.
    return { version: 1, revision: 0, note: '', checked: [] };
  }
  if (typeof raw !== 'string' || raw.length > RECORD_BYTE_LIMIT ||
      utf8Length_(raw) > RECORD_BYTE_LIMIT) {
    throw new ChecklistError_('STATE');
  }
  let state;
  try {
    state = JSON.parse(raw);
  } catch (_error) {
    throw new ChecklistError_('STATE');
  }
  if (!exactKeys_(state, ['version', 'revision', 'note', 'checked']) ||
      state.version !== 1 || !Number.isSafeInteger(state.revision) ||
      state.revision < 1 || !validNote_(state.note)) {
    throw new ChecklistError_('STATE');
  }
  return {
    version: 1,
    revision: state.revision,
    note: state.note,
    checked: normalizeChecked_(state.checked, 'STATE'),
  };
}

/**
 * @param {ChecklistState_} state
 * @param {ChecklistDraft_} draft
 * @returns {boolean}
 */
function sameChecklist_(state, draft) {
  return state.note === draft.note &&
    state.checked.length === draft.checked.length &&
    state.checked.every((value, index) => value === draft.checked[index]);
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, unknown>}
 */
function isObject_(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, string>}
 */
function isStringRecord_(value) {
  return isObject_(value) && Object.values(value).every((entry) => typeof entry === 'string');
}

/**
 * @param {unknown} value
 * @param {string[]} keys
 * @returns {boolean}
 */
function exactKeys_(value, keys) {
  return isObject_(value) && Object.keys(value).length === keys.length &&
    keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
}

/**
 * @param {string} text
 * @returns {number}
 */
function utf8Length_(text) {
  return Utilities.newBlob(text).getBytes().length;
}

/**
 * @param {string} functionName
 * @param {Record<string, string>} parameters
 * @returns {CardService.Action}
 */
function cardAction_(functionName, parameters) {
  return CardService.newAction()
    .setFunctionName(functionName)
    .setParameters(parameters)
    .setLoadIndicator(CardService.LoadIndicator.SPINNER)
    .setPersistValues(false);
}

/**
 * CardService's generated declarations omit the shared widget base type for
 * input widgets, although the runtime accepts them in a card section.
 *
 * @param {CardService.SelectionInput | CardService.TextInput} widget
 * @returns {CardService.Widget}
 */
function inputWidget_(widget) {
  return /** @type {CardService.Widget} */ (/** @type {unknown} */ (widget));
}

function reloadButton_() {
  return CardService.newTextButton()
    .setText('Reload saved')
    .setOnClickAction(cardAction_('reloadChecklist', { operation: 'reload' }));
}

/**
 * @param {ChecklistState_} state
 * @returns {CardService.Card}
 */
function buildChecklistCard_(state) {
  const progress = state.revision === 0
    ? 'No saved checklist yet. 0 of 3 checked.'
    : 'Saved progress: ' + state.checked.length + ' of 3 checked. Revision ' + state.revision + '.';
  const choices = CardService.newSelectionInput()
    .setType(CardService.SelectionInputType.CHECK_BOX)
    .setFieldName('checked')
    .setTitle('Completed items');
  for (const step of CHECKLIST_STEPS) {
    choices.addItem(step.label, step.id, state.checked.includes(step.id));
  }
  const note = CardService.newTextInput()
    .setFieldName('note')
    .setTitle('Next-step note')
    .setHint('Optional; one line, up to 120 characters. Use fictional data.')
    .setMultiline(false)
    // A widget value, not interpolated HTML or a formatted TextParagraph.
    .setValue(state.note);
  const save = CardService.newTextButton()
    .setText('Save checklist')
    .setOnClickAction(cardAction_('saveChecklist', {
      operation: 'save',
      revision: String(state.revision),
    }));
  const buttons = CardService.newButtonSet().addButton(save).addButton(reloadButton_());
  const section = CardService.newCardSection()
    .addWidget(CardService.newTextParagraph().setText(progress))
    .addWidget(CardService.newTextParagraph().setText(
      'One personal checklist, not linked to a selected file. No files or permissions are checked.',
    ))
    .addWidget(inputWidget_(choices))
    .addWidget(inputWidget_(note))
    .addWidget(CardService.newTextParagraph().setText(
      'Edits are not saved until Save checklist. Reload saved discards unsaved edits.',
    ))
    .addWidget(buttons);
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('Handoff checklist'))
    .addSection(section)
    .build();
}

/**
 * @param {string} message
 * @returns {CardService.Card}
 */
function buildErrorCard_(message) {
  return CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('Checklist unavailable'))
    .addSection(CardService.newCardSection()
      .addWidget(CardService.newTextParagraph().setText(message))
      .addWidget(CardService.newButtonSet().addButton(reloadButton_())))
    .build();
}

/**
 * @param {ChecklistState_} state
 * @param {string} message
 * @param {boolean} changed
 * @returns {CardService.ActionResponse}
 */
function replaceCard_(state, message, changed) {
  return CardService.newActionResponseBuilder()
    .setNavigation(CardService.newNavigation().updateCard(buildChecklistCard_(state)))
    .setNotification(CardService.newNotification().setText(message))
    .setStateChanged(changed)
    .build();
}

/**
 * @param {string} message
 * @returns {CardService.ActionResponse}
 */
function notify_(message) {
  return CardService.newActionResponseBuilder()
    .setNotification(CardService.newNotification().setText(message))
    .build();
}

/**
 * @param {unknown} error
 * @returns {string}
 */
function messageFor_(error) {
  if (error instanceof ChecklistError_) {
    switch (error.code) {
      case 'EVENT':
        return 'Invalid card input. Reopen the installed add-on in Drive; nothing was written.';
      case 'NOTE':
        return 'Note not saved. Use one line of at most 120 characters without control characters.';
      case 'STEPS':
        return 'Checklist not saved. Choose only the three displayed items, without duplicates.';
      case 'STALE':
        return 'Not saved: the stored checklist changed. Keep your draft elsewhere, then Reload saved.';
      case 'BUSY':
        return 'Another checklist operation is busy. Wait, then try again; this action wrote nothing.';
      case 'STATE':
        return 'Stored checklist is invalid. It was not reset. Preserve it and ask the project owner for help.';
      case 'LIMIT':
        return 'Checklist storage limit reached. Nothing was written; ask the project owner for help.';
    }
  }
  return 'Could not confirm the result. Keep your draft elsewhere, then Reload saved before saving again.';
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "addOns": {
    "common": {
      "name": "Handoff checklist",
      "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/check_circle_black_48dp.png",
      "homepageTrigger": {
        "runFunction": "onHomepage",
        "enabled": true
      }
    },
    "drive": {}
  }
}