Google Workspace APIs skill for Codex
Right principal, smallest scope, no stray keys. Codex picks the right identity for a Google Workspace integration before writing code: user OAuth, a plain service account, or domain-wide delegation, including keyless signing when key creation is blocked. It chooses the narrowest scopes, explains verification costs for restricted scopes, adds backoff for quota errors and decodes invalid_grant, unauthorized_client and accessNotConfigured into concrete fixes.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- google-workspace-apis Installs to ~/.agents/skills/google-workspace-apis/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install google-workspace-apis The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project google-workspace-apis Installs to .agents/skills/google-workspace-apis/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/google-workspace-apis.zipunzip -o google-workspace-apis.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) google-workspace-apis PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $google-workspace-apis, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up authentication for Google Workspace APIs (Drive, Gmail, Calendar, Sheets, Docs, Admin SDK, Chat) - choosing between user OAuth, service accounts, domain-wide delegation and keyless signing, picking least-privilege scopes, consent screen verification, quotas, batching and backoff with google-auth, google-api-python-client and googleapis for Node. Use when a Google API call fails with invalid_grant, unauthorized_client, accessNotConfigured or insufficientPermissions, or when starting any integration that acts on Workspace users' data.
Use it when
- Starting any integration with Drive, Gmail, Calendar, Sheets, Docs or Admin SDK
- Deciding between user OAuth, a service account and domain-wide delegation
- A Google API call fails with invalid_grant, unauthorized_client or a 403
- Your organisation blocks service account key creation
Use something else when
- Auth already works and you need Drive file operations: use google-drive-api
- Auth already works and you need mailbox sync or sending: use gmail-api
- You are integrating a generic third-party OAuth provider: use oauth-integrations
How to check the result
Inspect the granted scopes on a refreshed token, make one read call that reports which user it ran as, then expire the cached token and confirm the job still runs unattended.
The mistake it prevents
Leaving an External OAuth app in Testing status, which silently expires refresh tokens after seven days.
Done well means: the integration authenticates as the right principal for the job, requests the narrowest scopes that work, holds no long-lived key file if it can avoid one, and survives quota errors with backoff instead of crashing.
Choose the principal
| Situation | Principal | Notes |
|---|---|---|
| A person runs a tool on their own data | User OAuth (installed app or web flow) | Refresh token per user; consent screen required |
| SaaS product acting for many customers’ users | User OAuth, web flow, access_type=offline | Sensitive/restricted scopes need Google verification |
| Backend job on files/calendars it owns or that are shared to it | Service account, no delegation | Lives outside any user; share resources to its email |
| Backend job acting as users of one Workspace domain | Service account + domain-wide delegation (DWD) | A super admin authorises client ID + scopes |
| Admin SDK (users, groups, devices) | DWD impersonating an admin, or admin OAuth | Directory API needs an admin identity |
| Public data only (no user data) | API key | Never for Workspace user data |
Ask the user which of these applies when it is not obvious. DWD is powerful: it can read every mailbox for the granted scopes. Do not propose it when per-user OAuth would do.
Before writing code
- [ ] Read AGENTS.md for secret handling and the existing Google client library
- [ ] Identify the Cloud project; enable each API (gcloud services enable drive.googleapis.com gmail.googleapis.com)
- [ ] Pick the principal (table above) and the exact scopes (table below)
- [ ] OAuth: configure the consent screen (Internal for single-domain tools, External otherwise)
- [ ] DWD: get the service account's numeric client ID for the admin to authorise
- [ ] Decide where tokens/keys live (secret manager, not the repo)
gcloud and every API call need network; in a Codex sandbox with network off, prepare code
and commands and ask the user to run or approve them.
Scopes: narrowest that works
| Need | Prefer | Avoid unless required |
|---|---|---|
| Files the app creates or the user picks | drive.file (non-sensitive) | drive (restricted) |
| Read Drive metadata | drive.metadata.readonly | drive.readonly |
| Send mail only | gmail.send (sensitive) | gmail.modify, mail.google.com |
| Read mail | gmail.readonly (restricted) | mail.google.com (full, incl. delete) |
| Calendar events | calendar.events | calendar |
| Sheets the app opens via picker | drive.file + Sheets API | spreadsheets on all sheets |
| Directory users read | admin.directory.user.readonly | admin.directory.user |
All scopes are prefixed https://www.googleapis.com/auth/. Restricted scopes (most of Gmail,
full Drive) require an annual third-party security assessment for External apps. In
External apps left in “Testing” status, refresh tokens expire after 7 days: that, not a bug, is
the usual cause of weekly invalid_grant.
User OAuth (Python)
from pathlib import Path
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/drive.file"]
TOKEN = Path("~/.config/mytool/token.json").expanduser() # outside the repo
def creds():
c = Credentials.from_authorized_user_file(TOKEN, SCOPES) if TOKEN.exists() else None
if c and c.expired and c.refresh_token:
c.refresh(Request())
elif not c or not c.valid:
c = InstalledAppFlow.from_client_secrets_file("client_secret.json", SCOPES).run_local_server(port=0)
TOKEN.parent.mkdir(parents=True, exist_ok=True)
TOKEN.write_text(c.to_json())
return c
drive = build("drive", "v3", credentials=creds(), cache_discovery=False)
Web apps: use the authorization-code flow with access_type=offline and prompt=consent only
on first connect (forcing consent every time issues new refresh tokens and hits the per-client
token cap). Store refresh tokens encrypted per user; handle revocation by asking the user to
reconnect.
Service account with domain-wide delegation
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/calendar.events"]
base = service_account.Credentials.from_service_account_file("/run/secrets/sa.json", scopes=SCOPES)
as_user = base.with_subject("[email protected]") # impersonate this Workspace user
cal = build("calendar", "v3", credentials=as_user, cache_discovery=False)
Admin side (super admin, Admin console -> Security -> Access and data control -> API controls ->
Domain-wide delegation): add the service account’s numeric client ID and the exact scope list.
The scopes in code must be a subset of the authorised list or token exchange fails with
unauthorized_client. Changes can take minutes to propagate.
Keyless: no JSON key at all
Many organisations block key creation (iam.disableServiceAccountKeyCreation). On GCP or with
workload identity federation, sign the DWD assertion through the IAM Credentials API instead:
import google.auth
from google.auth import iam
from google.auth.transport.requests import Request
from google.oauth2 import service_account
SA = "[email protected]"
source, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
signer = iam.Signer(Request(), source, SA)
creds = service_account.Credentials(
signer=signer, service_account_email=SA,
token_uri="https://oauth2.googleapis.com/token",
scopes=SCOPES, subject="[email protected]",
)
The runtime identity needs roles/iam.serviceAccountTokenCreator on that service account.
Node (googleapis)
import { google } from "googleapis";
const auth = new google.auth.JWT({
keyFile: process.env.GOOGLE_SA_KEY_FILE,
scopes: ["https://www.googleapis.com/auth/admin.directory.user.readonly"],
subject: "[email protected]",
});
const admin = google.admin({ version: "directory_v1", auth });
const { data } = await admin.users.list({ customer: "my_customer", maxResults: 200 });
Quotas, batching, partial responses
- Errors to retry with exponential backoff and jitter: 429, 500, 503, and 403 with reason
rateLimitExceededoruserRateLimitExceeded. Do not retry 403insufficientPermissionsoraccessNotConfigured. - Quotas are per project per minute and often per user per minute; spreading load across impersonated users does not raise the project limit.
- Request only needed fields:
fields="nextPageToken, files(id, name, modifiedTime)". - Batch HTTP requests (up to 100 calls per batch in most APIs, fewer for Gmail in practice) cut round trips but each inner call still counts against quota.
- Python’s
HttpRequest.execute(num_retries=5)adds backoff for retryable errors.
Error decoder
| Error | Usual cause | Fix |
|---|---|---|
invalid_grant | Refresh token revoked or expired (Testing status, user removed access, password change on Gmail scopes); clock skew for JWT | Re-consent; publish the app; sync time |
unauthorized_client | DWD not set or scopes in code exceed the authorised list | Match scopes exactly in Admin console |
403 accessNotConfigured | API not enabled in the project that owns the credentials | Enable the API, wait a few minutes |
403 insufficientPermissions | Token lacks the scope | Re-consent with the scope |
| 404 on a known file/calendar | Service account (not impersonating) has no access | Share to the SA email or use DWD |
admin_policy_enforced | Workspace admin blocks the app or scope | Admin must trust the app in API controls |
Gotchas
- A service account is not a Workspace user: it has no Gmail mailbox and, without DWD, sees nothing in users’ Drives or calendars.
with_subjectmust be a real user in the domain; groups and aliases fail.- Consumer
@gmail.comaccounts cannot be impersonated; DWD is Workspace-only. - Do not commit
client_secret.json,token.jsonor service account keys; add them to.gitignoreand scan before committing. - Apps Script is often the simpler answer for in-domain automations owned by one team.
Verification
- Print the token’s granted scopes (
creds.scopesafter refresh, or callhttps://oauth2.googleapis.com/tokeninfo?access_token=...) and compare with intent. - Make one read call as the intended principal and log who it ran as (e.g. Drive
about.getwithfields="user"). - Force a refresh (expire the cached token) and confirm the job still runs unattended.
For Drive specifics use $google-drive-api; for Gmail sync and push use $gmail-api.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Google Workspace APIs"
short_description: "Right principal, smallest scope, no stray keys"
default_prompt: "$google-workspace-apis Set up a service account that can create calendar events for anyone in our domain" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.