Google Workspace — auth + calling Gmail/Drive/Calendar/Sheets

SkillCommunication

Use when server-side code reads or writes Gmail, Drive, Calendar, or Sheets with a GCP service account and no human in the OAuth loop: picking the auth mode (app-owned vs domain-wide delegation vs keyless), scoping to least privilege, building the authed Node/Python client, staying under per-user quota, and debugging unauthorized_client / 403 / 429. NOT SMTP providers or deliverability (that is `email-connector`), NOT slot-finding and booking UX (that is `calendar-scheduling`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Google Workspace — auth + calling Gmail/Drive/Calendar/Sheets skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/google-workspace/SKILL.md and read by ahel’s review.

This skill owns one layer: authenticating to and calling the four Google Workspace REST APIs from server-side code. Everything here is service-account / machine auth — if a real user must click "Allow", that is interactive OAuth and out of scope.

Where the neighbouring layers live:

Not this skillGoes toThis skill's slice
SMTP/provider choice, transactional/marketing sendsemail-connectorGmail-the-API inside a Workspace mailbox
SPF/DKIM, inbox placementemail-deliverability
Availability search, booking-link UX, timezone-as-a-featurecalendar-schedulingRaw Calendar event CRUD underneath
Sheet data modeling, formulas, pivotsspreadsheet-opsSheets API read/write transport
Doc/PDF parsing, extraction, OCRdocument-processingDrive as storage transport (upload/download/move/permissions)
Chaining several connectors into one flowautomation-flowsThe individual Google calls
Notion as the backend / generic REST wrappingnotion-connector, api-connector-builder
Receiving Gmail/Drive push notificationswebhooks

Pick your auth mode

Choose first — it dictates scopes, the Admin-console step, and the client build.

SituationModeWhy
App owns the data (its own Drive folder, its own calendar, a shared drive it was added to)Service account, no delegationThe SA is its own identity; no need to act as a human. Simplest, no Admin step.
Must act AS each Workspace user (send from ops@acme.com, read their inbox/calendar)Service account + domain-wide delegation + subjectGmail has no "shared mailbox via SA" — to touch a user's mail/calendar you impersonate them. Requires a Workspace admin to authorize the SA.
Code runs on GCP (Cloud Run, GKE, Functions) or CI with WIFKeyless: Application Default Credentials / Workload Identity FederationNo long-lived key file to leak or rotate. The runtime mints short-lived tokens. Always prefer this when the platform supports it.

Rule: never reach for domain-wide delegation if app-owned resources suffice. DWD lets the SA impersonate anyone in the org for the granted scopes — it is a large blast radius. Use it only when you genuinely must act as the user.

Setup checklist

Do these in order. references/auth-setup.md has the full Cloud + Admin click-path, the scope catalog, DWD authorization, keyless WIF/ADC, and a longer troubleshooting matrix.

  1. Enable the APIs you will call in the Cloud console (Gmail, Drive, Calendar, Sheets) for the project. A disabled API returns 403 regardless of scopes.
  2. Create the service account in IAM & Admin → Service Accounts. For keyless you stop here and attach the SA to the runtime; for a key you create a JSON key (and treat it like a password — see Security).
  3. Decide scopes (next section) — the exact scope strings you will request.
  4. Authorize DWD only if impersonating. In the Admin console → Security → Access and data control → API controls → Manage Domain Wide Delegation, add the SA's client ID (the numeric client_id, not the email) plus the exact comma-separated scope list. A scope requested in code but not authorized here is the #1 cause of unauthorized_client.

Scopes: least privilege

Request the narrowest scope that does the job. Broad scopes also force a stricter Google verification review and widen what a leaked key can touch.

# Bad — full read/write to ALL of the user's Drive
https://www.googleapis.com/auth/drive

# Good — only files this app created or was explicitly shared
https://www.googleapis.com/auth/drive.file
TaskScopeNote
Send mail onlygmail.sendCannot read the inbox — ideal for notifications.
Read mailgmail.readonlyRead, no modify/delete.
Modify labels/stategmail.modifyAvoid full mail.google.com unless you truly need delete + settings.
App-created Drive filesdrive.fileCannot see the user's other files — smallest footprint.
Read all Drivedrive.readonlyPrefer over full drive.
Calendar eventscalendar.eventsNarrower than full calendar.
Read/write SheetsspreadsheetsUse spreadsheets.readonly if you only read.

Build the authed client

Node uses googleapis (latest 173.x, maintenance mode — bugs/security only) with google-auth-library (10.6.2). Python uses google-auth + google-api-python-client. The impersonation line is the subject / with_subject.

// Node — service account, optionally impersonating a Workspace user.
import { google } from 'googleapis';

const auth = new google.auth.JWT({
  email: process.env.SA_CLIENT_EMAIL,
  key: process.env.SA_PRIVATE_KEY.replace(/\\n/g, '\n'), // from secret mgr, never a file in the repo
  scopes: ['https://www.googleapis.com/auth/gmail.send'],
  subject: 'ops@acme.com', // omit this line for app-owned (no-delegation) mode
});
const gmail = google.gmail({ version: 'v1', auth });
// Node — keyless on GCP (Cloud Run / GKE / CI with WIF). No key in code at all.
import { google } from 'googleapis';
const auth = new google.auth.GoogleAuth({
  scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
});
const sheets = google.sheets({ version: 'v4', auth });
# Python — service account from credentials, impersonating a user.
from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/gmail.send"]
creds = service_account.Credentials.from_service_account_info(
    sa_info, scopes=SCOPES         # sa_info loaded from secret mgr, not a tracked file
).with_subject("ops@acme.com")     # drop .with_subject(...) for app-owned mode
gmail = build("gmail", "v1", credentials=creds, cache_discovery=False)

Per-API recipes (short)

Copy-paste-ready minimums. Longer recipes (raw MIME with attachments, resumable uploads, batchUpdate, recurring/timezone-correct events) are in references/api-recipes.md.

// Gmail: send. Body must be base64url-encoded RFC 822 (note -_ , no padding).
const raw = Buffer.from(
  'To: a@acme.com\r\nSubject: Report\r\n\r\nHello.'
).toString('base64url');
await gmail.users.messages.send({ userId: 'me', requestBody: { raw } });
// Drive: create a file, then grant read to one person (least-privilege share).
const file = await drive.files.create({
  requestBody: { name: 'report.pdf' },
  media: { mimeType: 'application/pdf', body: stream },
  fields: 'id', // partial response — ask only for what you use
});
await drive.permissions.create({
  fileId: file.data.id,
  requestBody: { role: 'reader', type: 'user', emailAddress: 'a@acme.com' },
});
# Calendar: insert an event (always send explicit IANA timeZone).
event = {
    "summary": "Sync",
    "start": {"dateTime": "2026-06-10T10:00:00", "timeZone": "Europe/Andorra"},
    "end":   {"dateTime": "2026-06-10T10:30:00", "timeZone": "Europe/Andorra"},
}
cal.events().insert(calendarId="primary", body=event).execute()
# Sheets: write a range. Use values.batchUpdate to write many ranges in one call.
sheets.spreadsheets().values().update(
    spreadsheetId=SID, range="Sheet1!A2",
    valueInputOption="USER_ENTERED",
    body={"values": [["2026-06-02", 1290]]},
).execute()

Stay under quota

The per-user ceiling is the one that bites a cron looping over a mailbox.

  • Gmail: 1.2M units/min per project, 6,000 units/min per user, 80M units/day. Costs: messages.send 100, messages.get 20, messages.list 5, messages.modify 5, drafts.create 10. Hard cap 500 recipients/message.
  • Drive: 1M units/min per project, 325,000 units/min per user, 1 TB/day egress.
  • Sheets: read and write each 300/min per project, 60/min per user; 429 on overage; 180s request timeout; keep payloads under ~2 MB.
  • Policy shift: as of 2026-05-01 Google updated Workspace quota policy — projects active Nov 2025–Apr 2026 keep legacy quotas, new projects get the new model, and overage will start incurring Cloud billing charges later in 2026. Treat quota as a cost line, not a free ceiling.

Three habits keep you under it:

  1. fields partial responses — ask only for the fields you read; smaller responses, lower cost, faster.
  2. Batch — Sheets values.batchUpdate, Gmail batch requests, Drive batch — one call instead of N cuts per-user request count directly.
  3. Exponential backoff with jitter on 403 rateLimitExceeded and 429 — retrying immediately just burns more quota.
# Backoff: min((2^n) + random_ms, max_backoff). Cap 32–64s. Jitter avoids
# thundering-herd retries syncing up.
import random, time
from googleapiclient.errors import HttpError

def with_backoff(call, max_retries=6, max_backoff=64):
    for n in range(max_retries):
        try:
            return call()
        except HttpError as e:
            if e.resp.status not in (403, 429) or n == max_retries - 1:
                raise
            time.sleep(min((2 ** n) + random.random(), max_backoff))

Security rules

  • Never commit the SA key JSON. It is a long-lived bearer credential — a committed service_account.json is game over. Add *.json SA patterns to .gitignore; the verify.sh here flags tracked keys.
  • Prefer keyless. A leaked key is the single most common Workspace credential compromise. On GCP/CI use ADC or Workload Identity Federation so there is no file to leak. If you must use a key, store it in a secret manager (env-injected, not a file beside the code) and rotate it.
  • Least scope. A leaked drive.file key sees app files; a leaked full drive key sees everything. The scope IS the blast radius.
  • Map the error before you change anything:
ErrorLikely causeFix
unauthorized_clientSA client ID / scope not authorized for DWDAdd the client ID + exact scopes in Admin console Manage DWD
403 insufficient permissionsScope too narrow, or API not enabledWiden to the right scope (still least), enable the API
403 rateLimitExceeded / 429Per-user or per-project quota hitExponential backoff + jitter; batch; spread load
400 failedPrecondition on impersonationsubject set but DWD not configuredEither remove subject (app-owned) or finish DWD setup
invalid_grantClock skew or stale/rotated keySync clock; re-issue the key

Anti-patterns

Anti-patternWhy it breaksDo instead
Committing service_account.json to the repoLong-lived key in git history = full compromise; can't un-leakKeyless ADC/WIF, or key in a secret manager + .gitignore
Requesting auth/drive / mail.google.com "to be safe"Max blast radius, stricter Google review, more to leakNarrowest scope: drive.file, gmail.send, spreadsheets.readonly
Using DWD subject for app-owned dataImpersonating users when the SA could own the resource — needless blast radius + an Admin dependencyDrop subject; let the SA own the folder/calendar/shared drive
Looping messages.send/values.update per row with no backoffTrips the 6k/min (Gmail) or 60/min (Sheets) per-user cap → 429 stormBatch (values.batchUpdate) + exponential backoff with jitter
Reading whole resources without fieldsBigger payloads, higher quota cost, slowerRequest only the fields you use (fields: 'id')
Hardcoding the private key inline in sourceCan't rotate, leaks via logs/screenshots/historyInject from env/secret manager; \n-unescape at load
Pasting raw text into Gmail rawAPI needs base64url RFC 822, not plain text → 400Build a MIME message, base64url-encode it

Before this connector ships, run the secret-handling and key-rotation pass in ../secure-coding/SKILL.md.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026

ahel review

  • S4info
    community integration — published by ericrisco, not google

Automated review, not a security audit. Ruleset v1.

Advanced
Catalog kind
skill
Gateway key
google-workspace-ericrisco
Source
github.com/ericrisco/rsc-harness