ShipAddons

Google Workspace

Multi-app support, OAuth scopes, and context detection

ShipAddons supports all major Google Workspace applications from a single codebase.

Supported Applications

ApplicationScript TypeContext Detection
Google SheetssheetsSpreadsheetApp.getActiveSpreadsheet()
Google DocsdocsDocumentApp.getActiveDocument()
Google SlidesslidesSlidesApp.getActivePresentation()
Google FormsformsFormApp.getActiveForm()

OAuth Scopes

The add-on requires specific OAuth scopes defined in appsscript.json:

{
  "oauthScopes": [
    "openid",
    "https://www.googleapis.com/auth/script.container.ui",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/documents.currentonly",
    "https://www.googleapis.com/auth/spreadsheets.currentonly",
    "https://www.googleapis.com/auth/presentations.currentonly",
    "https://www.googleapis.com/auth/forms.currentonly"
  ]
}

Scope Reference

ScopePurposeRequired
openidEnables ScriptApp.getIdentityToken() for authenticationYes
script.container.uiShow sidebar and dialog UI elementsYes
userinfo.emailAccess user's email addressYes
userinfo.profileAccess user's name and profile pictureYes
script.external_requestMake HTTP requests to your backend via UrlFetchAppYes
documents.currentonlyRead/write the currently open Google DocFor Docs
spreadsheets.currentonlyRead/write the currently open Google SheetFor Sheets
presentations.currentonlyRead/write the currently open Google SlidesFor Slides
forms.currentonlyRead/write the currently open Google FormFor Forms

Minimal vs Full Access

The .currentonly suffix limits access to only the document the user has open. This is a privacy best practice:

✓ spreadsheets.currentonly  →  Can only access the open spreadsheet
✗ spreadsheets              →  Can access ALL user's spreadsheets

Users are more likely to authorize add-ons with minimal permissions.

Multi-App Architecture

Separate Clasp Configurations

Each Google app type requires its own Apps Script project:

addon/
├── .clasp-sheets.json    # Sheets project
├── .clasp-docs.json      # Docs project
├── .clasp-slides.json    # Slides project
└── .clasp-forms.json     # Forms project

The setup script creates these configurations:

yarn run setup
# Prompts for which app types to configure

Script Commands

CommandDescription
yarn run pushPush to all configured apps
yarn run push:sheetsPush to Sheets only
yarn run push:docsPush to Docs only
yarn run openOpen all projects in browser
yarn run open:sheetsOpen Sheets project

Shared Codebase

All app types share the same source code. The built output is pushed to each Apps Script project.

src/
├── client/           # React UI (shared)
├── server/           # Apps Script functions (shared)
└── ...

Context Detection

The add-on detects which Google app it's running in:

// addon/src/server/utils.ts

export function detectScriptContext(): ScriptContextType {
  try {
    const ui = SpreadsheetApp.getUi();
    if (ui) {
      return "sheets";
    }
  } catch (_eSheet) {
    try {
      const docUi = DocumentApp.getUi();
      if (docUi) {
        return "docs";
      }
    } catch (_eDoc) {
      try {
        const slideUi = SlidesApp.getUi();
        if (slideUi) {
          return "slides";
        }
      } catch (_eSlide) {
        try {
          const formUi = FormApp.getUi();
          if (formUi) {
            return "forms";
          }
        } catch (_eForm) {
          throw new Error("Unknown script context");
        }
      }
    }
  }
  throw new Error("Unknown script context");
}

Client-Side Usage

import serverFunctions from "../serverFunctions";

function Home() {
  const [context, setContext] = useState<string>("");

  useEffect(() => {
    serverFunctions.detectScriptContext().then(setContext);
  }, []);

  // Render context-specific UI
  switch (context) {
    case "sheets":
      return <HomeSheets />;
    case "docs":
      return <HomeDocs />;
    case "slides":
      return <HomeSlides />;
    case "forms":
      return <HomeForms />;
    default:
      return <HomeGeneric />;
  }
}

Per-App Home Components

Each Google app can have its own Home component:

components/
├── Home.tsx              # Main router
├── sheets/
│   └── HomeSheets.tsx    # Sheets-specific UI
├── docs/
│   └── HomeDocs.tsx      # Docs-specific UI
├── slides/
│   └── HomeSlides.tsx    # Slides-specific UI
└── forms/
    └── HomeForms.tsx     # Forms-specific UI

Example: Sheets-Specific Features

// components/sheets/HomeSheets.tsx

import serverFunctions from "../../serverFunctions";

export function HomeSheets() {
  const handleGetRange = async () => {
    const data = await serverFunctions.getSelectedRange();
    console.log("Selected range:", data);
  };

  return (
    <div>
      <h2>Sheets Add-on</h2>
      <Button onClick={handleGetRange}>Get Selected Range</Button>
    </div>
  );
}

With corresponding server function:

// server/sheets.ts

export function getSelectedRange(): string[][] {
  const sheet = SpreadsheetApp.getActiveSpreadsheet();
  const range = sheet.getActiveRange();
  return range.getValues();
}

The add-on adds a menu to each Google app:

// server/ui.ts

function getActiveUiForContext(context?: ScriptContextType) {
  let ctx = context;
  if (!context) {
    ctx = detectScriptContext();
  }
  switch (ctx) {
    case "docs":
      return DocumentApp.getUi();
    case "slides":
      return SlidesApp.getUi();
    case "sheets":
      return SpreadsheetApp.getUi();
    case "forms":
      return FormApp.getUi();
    default:
      throw new Error("Unknown script context");
  }
}

export const onOpen = () => {
  const menu = getActiveUiForContext().createAddonMenu();

  menu.addItem("Sidebar", "openSidebar");
  menu.addSeparator();
  menu.addItem("Dialog", "openDialog");

  menu.addToUi();
};

UI Elements

Full-height panel on the right side of the document:

export const openSidebar = () => {
  const html =
    HtmlService.createHtmlOutputFromFile("sidebar").setTitle(EXTENSION_NAME);
  getActiveUiForContext().showSidebar(html);
};

Sidebar constraints:

  • Fixed width (~300px)
  • Full document height
  • Persists while document is open

Dialog

Modal window centered on screen:

export const openDialog = () => {
  const html = HtmlService.createHtmlOutputFromFile("dialog")
    .setWidth(1280)
    .setHeight(720);
  getActiveUiForContext().showModalDialog(html, EXTENSION_NAME);
};

Dialog constraints:

  • Custom width/height
  • Blocks interaction with document
  • Closes on user action

On this page