Google Workspace Add-ons let you inject custom UI directly into Gmail and Calendar --- a panel in the right-hand sidebar that can read the current email or event, call your own backend, and send it to your own backend. If you've ever wanted a personal "second brain" that quietly stores the emails and meetings that matter to you, without ever leaving your inbox, this is the tool for the job.
This post walks through everything involved in building one for real: project setup, Auth0 sign-in, the CardService UI model (and its very real constraints), and a handful of hard-won lessons from building a meeting-store feature that syncs Calendar events into a custom backend.
The example throughout: a knowledge hub app --- think of it as a private journal that automatically captures important emails and meetings you flag from Gmail/Calendar, so you have a searchable personal record without manual copy-pasting.
1. What you're actually building
A Workspace Add-on is a server-rendered UI that Gmail/Calendar hosts inside an iframe-like panel. There's no DOM access, no custom CSS, no client-side JavaScript. Every button, every text block, every layout choice is described declaratively in Apps Script using CardService, and Google renders it into native-looking widgets. This is the single biggest mental shift if you're coming from web development --- you're not building a webpage, you're building a spec that Gmail interprets.
The add-on we'll reference throughout: a Gmail + Calendar add-on that stores emails and meetings into a personal application, written in TypeScript and compiled to Apps Script. Since it's single-user by design (your own knowledge hub, not a multi-org product), there's no organization concept to manage --- just "is this user signed in with Auth0 or not."
2. Project setup
2.1 Tooling
Apps Script projects live in Google's own runtime, but you don't have to
write vanilla .gs files. Using
clasp (Command Line Apps Script
Projects), you can write TypeScript locally, transpile it, and push the
compiled output to Apps Script.
npm install -g @google/clasp
clasp login
clasp create --type standalone --title "Knowledge Hub Add-on"A minimal tsconfig.json for Apps Script:
{
"compilerOptions": {
"target": "ES2019",
"module": "none",
"lib": ["ES2019"],
"types": ["google-apps-script"],
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*.ts"]
}Install the type definitions so CardService, Gmail, and Calendar APIs autocomplete properly:
npm install --save-dev @types/google-apps-script2.2 The manifest --- appsscript.json
This file declares which Google APIs and OAuth scopes your add-on needs, and where it hooks into Gmail/Calendar:
{
"timeZone": "Asia/Kathmandu",
"dependencies": {
"enabledAdvancedServices": [
{ "userSymbol": "Calendar", "version": "v3", "serviceId": "calendar" }
]
},
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
"https://www.googleapis.com/auth/calendar.addons.execute",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/userinfo.email"
],
"gmail": {
"name": "Knowledge Hub",
"logoUrl": "https://example.com/logo.png",
"primaryColor": "#4A86E8",
"contextualTriggers": [
{
"unconditional": {},
"onTriggerFunction": "onGmailMessage"
}
]
},
"calendar": {
"eventOpenTrigger": {
"runFunction": "onCalendarEventOpen"
}
},
"addOns": {
"common": {
"name": "Knowledge Hub",
"logoUrl": "https://example.com/logo.png"
}
}
}Since this is personal-use only, the scope list stays lean --- no Drive access, no external organization routing, just Gmail/Calendar read access plus one external HTTP scope to talk to your own backend.
2.3 File organization
Apps Script has no real module system, so a common pattern is one file per concern, relying on the fact that everything shares a global scope at runtime:
src/
Code.ts // entry points: onGmailMessage, onCalendarEventOpen
Auth.ts // Auth0 sign-in and token handling
Meeting.ts // meeting synchronization flow
Email.ts // email store flow
Utils.ts // safeParams, ICS parsing helpers3. Authentication with Auth0
Because this is a personal, single-user app, auth is refreshingly simple compared to a multi-organization product: there's exactly one Auth0 identity to check for, and once it's connected, every card just checks "do I have a valid token."
3.1 Setting up the Auth0 service
The OAuth2 for Apps Script
library handles
the token dance. Add it as a library (script ID:
1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF), then:
function getAuth0Service(): GoogleAppsScriptOAuth2.OAuth2Service {
return OAuth2.createService("auth0")
.setAuthorizationBaseUrl("https://your-organization.auth0.com/authorize")
.setTokenUrl("https://your-organization.auth0.com/oauth/token")
.setClientId(
PropertiesService.getScriptProperties().getProperty("CLIENT_ID")!,
)
.setClientSecret(
PropertiesService.getScriptProperties().getProperty("CLIENT_SECRET")!,
)
.setCallbackFunction("authCallback")
.setPropertyStore(PropertiesService.getUserProperties())
.setScope("openid profile email offline_access")
.setParam("audience", "https://your-personal-store-api.com")
.setParam("access_type", "offline");
}
function authCallback(request: object): GoogleAppsScript.HTML.HtmlOutput {
const service = getAuth0Service();
const authorized = service.handleCallback(request);
return HtmlService.createHtmlOutput(
authorized
? "Success! You can close this tab."
: "Denied. You can close this tab.",
);
}PropertyStore is set to getUserProperties() --- since it's your own
account using your own add-on, the token just needs to persist per
Google user, with no organization dimension to key it on at all.
3.2 A simple sign-in gate
Every entry point starts the same way: check if Auth0 access exists, and if not, show a sign-in prompt instead of the main card. No organization lookup, no routing --- just a single boolean gate:
function onGmailMessage(
e: GoogleAppsScript.Addons.EventObject,
): GoogleAppsScript.Card.Card[] {
const service = getAuth0Service();
if (!service.hasAccess()) {
return [buildSignInCard(service.getAuthorizationUrl())];
}
const token = service.getAccessToken();
return [buildMainCard(e, token)];
}
function buildSignInCard(authUrl: string): GoogleAppsScript.Card.Card {
const section = CardService.newCardSection().addWidget(
CardService.newTextParagraph().setText(
"Sign in with Auth0 to start archiving your emails and meetings.",
),
);
section.addWidget(
CardService.newTextButton()
.setText("Sign in")
.setOpenLink(CardService.newOpenLink().setUrl(authUrl)),
);
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle("Sign in required"))
.addSection(section)
.build();
}That's the entire auth surface for a personal app: one service, one gate, no organization resolution step to get wrong.
4. CardService: the UI model
CardService is declarative and server-rendered. No CSS, no custom fonts,
no inline styles. Your primary formatting tool is DecoratedText, which
gives you a top label, bottom label, and optional icon/button --- close
to as flexible as it gets.
4.1 A basic card
function buildMainCard(
e: GoogleAppsScript.Addons.EventObject,
token: string,
): GoogleAppsScript.Card.Card {
const section = CardService.newCardSection().setHeader("Message details");
section.addWidget(
CardService.newDecoratedText()
.setTopLabel("Subject")
.setText(e.gmail!.messageMetadata!.subject ?? "(no subject)"),
);
section.addWidget(
CardService.newTextButton()
.setText("Archive this email")
.setOnClickAction(
CardService.newAction()
.setFunctionName("storeEmail")
.setParameters({ messageId: e.gmail!.messageMetadata!.accessToken! }),
),
);
return CardService.newCardBuilder()
.setHeader(CardService.newCardHeader().setTitle("Knowledge Hub"))
.addSection(section)
.build();
}4.2 Propagating state through every widget
CardService cards are stateless between renders --- Apps Script doesn't
remember what was on screen a moment ago. Every interactive widget's
setParameters() needs the complete state required to reconstruct
context on the next click, not just what's new. A pagination bug is
almost always this: some widget on the "next page" button is missing a
parameter that a widget on the "previous page" button had.
For state too large or structured to pass as flat key/value parameters (e.g. which stored tags are toggled across paginated results), serialize it:
function buildTagRow(
tag: Tag,
tagEdits: Record<string, TagEdit>,
): GoogleAppsScript.Card.Widget {
const serialized = JSON.stringify(tagEdits);
return CardService.newDecoratedText()
.setText(tag.name)
.setSwitchControl(
CardService.newSwitch()
.setFieldName(`tag_${tag.id}`)
.setValue(String(tagEdits[tag.id]?.applied ?? false))
.setOnChangeAction(
CardService.newAction()
.setFunctionName("onTagToggle")
.setParameters({ tagId: tag.id, tagEdits: serialized }),
),
);
}This tagEdits JSON-blob-as-parameter pattern is the fix for the
classic "I paginate to page 2 and my page 1 selections vanish" bug ---
the state was never being carried forward, just implicitly assumed to
persist.
4.3 Two-widget layouts and read-only rows
A trick for laying out a checkbox next to label text with visual
indentation (since there's no padding/margin API): pair a Switch
widget with a TextParagraph that uses non-breaking spaces
( ) to fake indentation:
section.addWidget(
CardService.newTextParagraph().setText(" Tagged: Family"),
);For an email already stored, a read-only DecoratedText (no click
action, no switch) communicates "this is already saved" without extra
explanatory copy:
CardService.newDecoratedText()
.setTopLabel("Already stored")
.setText("Saved on " + storedDate)
.setIcon(CardService.Icon.STAR);4.4 Typeahead / search widgets
MULTI_SELECT widgets backed by an external data source are the closest
thing CardService has to a live-search autocomplete --- useful here for
picking existing tags or folders in your knowledge hub:
CardService.newSelectionInput()
.setType(CardService.SelectionInputType.MULTI_SELECT)
.setFieldName("tags")
.setTitle("Tags")
.setExternalDataSource(CardService.newAction().setFunctionName("searchTags"));The gotcha: the callback function must return a plain object shaped
like { items: [...] }, not a CardService builder response:
function searchTags(e: { parameters: { keyword: string } }) {
const results = queryArchiveTags(e.parameters.keyword);
return {
items: results.map((r) => ({
text: r.name,
value: r.id,
selected: false,
})),
};
}Returning a CardService.newCardBuilder()...build() here silently fails
--- this endpoint expects raw JSON, not a Card object.
4.5 Tab simulation with ButtonSet
CardService has no native tabs. A ButtonSet at the top of a card,
where each button re-renders a different section via updateCard,
simulates the effect --- handy for switching between "Details" and
"Attachments" views on an stored item:
function buildTabbedCard(
activeTab: "details" | "attachments",
): GoogleAppsScript.Card.Card {
const buttonSet = CardService.newButtonSet()
.addButton(
CardService.newTextButton()
.setText("Details")
.setOnClickAction(
CardService.newAction()
.setFunctionName("switchTab")
.setParameters({ tab: "details" }),
),
)
.addButton(
CardService.newTextButton()
.setText("Attachments")
.setOnClickAction(
CardService.newAction()
.setFunctionName("switchTab")
.setParameters({ tab: "attachments" }),
),
);
const section = CardService.newCardSection().addWidget(buttonSet);
// ...append tab-specific widgets based on `activeTab`
return CardService.newCardBuilder().addSection(section).build();
}
function switchTab(e: { parameters: { tab: "details" | "attachments" } }) {
return CardService.newActionResponseBuilder()
.setNavigation(
CardService.newNavigation().updateCard(buildTabbedCard(e.parameters.tab)),
)
.build();
}updateCard replaces the current card in place; pushCard stacks a new
one on top with a back button. Use updateCard for tab-like switches,
pushCard for drill-downs.
5. The meeting synchronization feature
This is where most of the real complexity lives. Archiving a Calendar event means parsing the event's ICS data, detecting whether it's new/updated/cancelled, and rendering the right UI in both the Calendar add-on and the Gmail add-on (since meeting invites also show up as email).
5.1 One parser, two entry points
Calendar events and Gmail invite emails both expose ICS data, but through different APIs. Rather than writing two parsers, funnel both paths through a single function:
function getMeetingDescriptionHtml(icsContent: string): string {
const vevent = extractVeventBlock(icsContent);
const dtstart = parseDtstart(vevent);
const summary = parseField(vevent, "SUMMARY");
const location = parseField(vevent, "LOCATION");
return `
<b>${escapeHtml(summary)}</b><br/>
${dtstart.toLocaleString()}<br/>
${location ? escapeHtml(location) + "<br/>" : ""}
`;
}5.2 Scoping DTSTART to the VEVENT block
A subtle but nasty bug: ICS files often contain a VTIMEZONE block
before the VEVENT block, and both can contain DTSTART-like fields
(TZID, DTSTART for timezone transition rules). A regex that isn't
scoped to VEVENT will happily match the wrong one --- producing an
event date that silently resolves to the Unix epoch.
The fix is to extract the VEVENT block first, then search only
within it:
function extractVeventBlock(ics: string): string {
const match = ics.match(/BEGIN:VEVENT([\s\S]*?)END:VEVENT/);
if (!match) throw new Error("No VEVENT block found in ICS content");
return match[0];
}
function parseDtstart(veventBlock: string): Date {
const match = veventBlock.match(/DTSTART(?:;[^:]*)?:(\d{8}T\d{6}Z?)/);
if (!match) throw new Error("DTSTART not found within VEVENT block");
return parseIcsDate(match[1]);
}Lesson: always scope regex extraction to the specific ICS component you care about. ICS is a flat text format with repeating field names across different component types --- treat it structurally, not as one big string to pattern-match.
5.3 Detecting updates via SEQUENCE
ICS events carry a SEQUENCE number that increments every time the
organizer edits the event. Comparing the incoming SEQUENCE against the
one stored at last store tells you whether to show "Already stored" or
"New updates available":
function getMeetingArchiveStatus(
eventId: string,
incomingSequence: number,
): "not_stored" | "up_to_date" | "update_available" {
const stored = getStoredArchiveRecord(eventId);
if (!stored) return "not_stored";
if (incomingSequence > stored.sequence) return "update_available";
return "up_to_date";
}section.addWidget(
CardService.newDecoratedText()
.setTopLabel("Archive status")
.setText(
status === "update_available"
? "New updates available"
: status === "up_to_date"
? "Already stored"
: "Not yet stored",
),
);5.4 Cancellation detection --- the MIME content-type gotcha
Cancelled meetings arrive as a separate ICS payload with METHOD:CANCEL
instead of METHOD:REQUEST. If your cancellation-prefix logic
(e.g. prepending "CANCELLED:" to a previously-stored meeting's title)
isn't firing consistently, check how you're reading the MIME content
type of the invite attachment --- a strict equality check against a
content-type string (e.g. "text/calendar") will silently fail against
variants like "text/calendar; method=CANCEL" or different casing:
function isCancellationInvite(contentType: string): boolean {
// Don't do: contentType === "text/calendar; method=CANCEL"
// Content-type strings carry extra parameters and vary in casing.
return contentType.toLowerCase().includes("method=cancel");
}Strict === checks against MIME strings are brittle by nature --- MIME
parameters (charset, method, boundary) get appended in whatever
order the sending client chooses. Parse out just the piece you need
instead of comparing the whole string.
5.5 Conditionally hiding UI based on invite context
Not every meeting card should show every widget --- e.g. a "View original invite" link only makes sense if the event actually came in as a Gmail invite rather than being created directly in Calendar. The decision of whether that context applies belongs in the calling function (the trigger handler), not buried inside a shared data-fetching utility:
// Good: context decision lives where the trigger fires
function onCalendarEventOpen(e: GoogleAppsScript.Addons.EventObject) {
const event = fetchCalendarEvent(e);
const inviteContext = tryResolveInviteContext(event); // returns null if none
return [
buildMeetingCard(
event,
{ showOriginalInvite: inviteContext !== null },
inviteContext,
),
];
}
// Bad: baking the "should I show this" decision into the fetch utility
// makes it unreusable for callers that don't need invite-context logic at allKeeping this separation means fetchCalendarEvent stays a pure
data-fetching function usable in more than one flow (Calendar open
trigger, Gmail invite trigger, a background sync job), while each caller
decides what UI implications follow from the data.
5.6 Recurring events and the composite ID
The Calendar Advanced Service returns recurring event instances with an
ID shaped like eventId_20260721T090000Z. If you're storing store
records keyed by event ID, split off the recurrence suffix so all
instances of a series map to the same base record where that's the
intended behavior:
function getBaseEventId(eventId: string): string {
return eventId.split("_")[0];
}5.7 Safe parameter handling
CardService action parameters occasionally arrive null or undefined
depending on which widget triggered the action (a Switch versus a
TextButton populate parameters slightly differently). Wrapping access
in a small helper avoids a class of "Cannot read properties of
undefined" runtime errors that are otherwise hard to reproduce locally:
function safeParams<T extends Record<string, string>>(
e: { parameters?: Partial<T> } | undefined,
): Partial<T> {
return e?.parameters ?? {};
}
// Usage
function onTagToggle(e: {
parameters?: { tagId?: string; tagEdits?: string };
}) {
const { tagId, tagEdits } = safeParams(e);
if (!tagId) return; // defensive early exit
const edits = tagEdits ? JSON.parse(tagEdits) : {};
// ...
}5.8 Stripping artifacts from stored HTML
Descriptions pulled from Calendar/Gmail sometimes carry inline border
styling artifacts (a leftover from how Google renders quoted content)
that look broken once stored into your own app's rich-text renderer. A
simple pass strips them before the payload goes out:
function stripBorderArtifacts(html: string): string {
return html.replace(/style="[^"]*border[^"]*"/gi, "");
}6. Putting the attachments section together
A small but easy-to-skip detail: when a card section lists attachments
you can optionally save alongside an stored email, a one-line
descriptive TextParagraph above the list ("Select which attachments to
include in your store") measurably reduces confusion versus a bare list
of checkboxes with no framing:
section.addWidget(
CardService.newTextParagraph().setText(
"Select which attachments to include in your store.",
),
);
attachments.forEach((att) => {
section.addWidget(
CardService.newDecoratedText()
.setText(att.filename)
.setSwitchControl(
CardService.newSwitch()
.setFieldName(`attachment_${att.id}`)
.setOnChangeAction(
CardService.newAction()
.setFunctionName("onAttachmentToggle")
.setParameters({ attachmentId: att.id }),
),
),
);
});7. Wrapping up
A few patterns worth carrying into any Workspace Add-on project, regardless of what backend you're archiving to:
- Auth for a personal app can stay genuinely simple. One Auth0
service, one
hasAccess()gate, no organization layer to accidentally get the ordering wrong on. - Context decisions belong with the caller, not the shared utility --- keep data-fetching pure and let trigger handlers decide what UI implications follow.
- State must be threaded through every widget explicitly. CardService has no implicit memory between renders.
- Treat ICS as structured data, not a flat string --- scope every
regex to the component (
VEVENT,VALARM, etc.) you're actually targeting. - MIME and content-type comparisons should never be strict equality --- parse out the piece you need.
- Recurring event IDs need normalization (
.split("_")[0]) before you use them as storage keys.
None of this is exotic --- it's the accumulated result of a lot of small edge cases, each one boring in isolation, that together make the difference between a personal add-on that mostly works and one that quietly, reliably stores every email and every meeting you care about, cancelled invites and recurring series included.