| name | google-workspace-setup |
| description | One-time setup for gws: install, OAuth, scopes, auto-approve. |
| license | MIT |
| compatibility | macOS and Linux. Requires a Google account. |
| metadata | {"gini":{"version":"3.8.0","author":"Gini","platforms":["macos","linux"],"prerequisites":{"commands":["gws"]}}} |
Google Workspace Setup
One-time onboarding for the Google Workspace skills (google-gmail, google-calendar, google-drive, google-docs, google-sheets, google-meet, google-forms). Installs gws and gcloud, signs the user into their own Google Cloud project, enables the Workspace APIs, captures an OAuth Desktop client through the inline Connect form, and completes gws auth login.
The OAuth client lives in the user's own GCP project. The Client ID and Client Secret are captured through the inline Connect form (request_connector tool) and stored in Gini's encrypted secret store — never write them to chat or logs, and never write client_secret.json to disk.
This skill is idempotent — re-running it re-verifies the install and lets the user widen scopes.
Provisioning vs signing in. The work splits in two: provisioning (install, gcloud auth login, project, APIs, request_connector) runs once, ever; signing in (gws auth login) runs again whenever the user's gws session expires. gcloud is only ever needed for provisioning — never for a re-auth. Step 0 decides which case you're in.
The Flow
This is the exact first-time sequence (Step 0 short-circuits the already-signed-in and stored-client cases before you reach it). Within the first-time flow do not branch into shortcuts, do not pre-ask whether they have an existing OAuth client, do not list completed actions retrospectively. Status messages are action-oriented: what the user must do next.
- The user asks Gini to do a Workspace thing (read mail, check calendar, share a Drive file, etc.).
- Confirm setup with the user.
- Install
gws and gcloud silently in the background.
- Run
gcloud auth login, which pops up the user's default browser for sign-in.
- After they sign in, create the Cloud project and enable the seven Workspace APIs in the background.
- Send a single chat bubble with the last-step instructions (two Cloud Console URLs) and call
request_connector — the inline form renders below the bubble.
- After the user pastes the credentials and clicks Save, ask for a tag for this account and call
skill_run google-account-login, which pops up the user's default browser for OAuth consent and registers the tagged account.
- After they sign in, the original ask resumes.
Step 0 — What state are we in?
There are three states. Decide which one you're in in this order before doing anything else — each later step is only needed when the earlier check fails. In short: already signed in? → else is the OAuth client stored? → else obtain it.
(a) Already signed in. If the user just wants to use Workspace, first check whether a registered Google account already has a live session. The "Connected Google accounts" block lists registered accounts; confirm one is valid with gws auth status (against that account's configDir) — a token_valid: true result means you're set. If so, you're already set up: skip the rest of this skill and resume the user's original ask. (If the user explicitly wants to connect another account, see "Adding another account later".)
(b) Not signed in, but the OAuth client is already stored. Call list_connectors and look for a connector named google-workspace-oauth. If it exists, the OAuth client is already provisioned — skip all provisioning: do not run gcloud, create a project, or call request_connector; none of it is needed again. Go straight to signing in via skill_run google-account-login. Which way depends on whether an account already exists:
- An account exists but its session expired → re-auth it by re-using its existing config dir so it keeps its id and tag — pass that account's
configDir (and tag), NOT a fresh login (which would mint a duplicate) and NOT adopt: true (which skips login and only works on an already-valid session):
- A tagged account (from the "Connected Google accounts" block) whose session expired →
{ tag: "<that account's tag>", configDir: "<that account's configDir from the block>" }.
- The default-dir session (
~/.config/gws) that has EXPIRED → { tag: "<its tag>", configDir: "~/.config/gws" } (non-adopt, so it actually re-logs in).
- No account registered yet — the common case when the Client ID/Secret were just entered through the Skills page's Enter ID & secret form (or a prior
request_connector) but nobody has signed in — → a fresh login: ask the user for a tag, then call skill_run google-account-login with a new { tag } and no configDir (this is Step 6).
Either way, finish with Step 8 (smoke test). (Edge cases: if gws is not on $PATH, run Step 2's install first, then sign in. If login fails with invalid_client, the stored client is broken — fall through to the full first-time flow to re-provision it.)
(c) No stored OAuth client. True first-time setup. Continue to Step 1 to obtain the client.
Step 1 — Confirm setup
Tell the user, in one short sentence, that Google Workspace isn't set up yet, and ask whether to set it up now. Wait for confirmation before doing anything.
If they say yes, proceed silently — do not narrate each substep. The user sees a chat bubble per milestone (sign in, last step), not per command.
Step 2 — Install gws and gcloud
Both installs are silent and run through terminal_exec. If a binary is already on $PATH, skip its install.
Detect first:
command -v gws
command -v gcloud
The canonical gws source — do not discover it
gws is github.com/googleworkspace/cli (npm @googleworkspace/cli, Homebrew googleworkspace-cli). This is the only source. Never web-search for the repo, release URL, or package name, and never download gws from any other GitHub repo, mirror, or URL. A look-alike (google-workspace-cli, gws-cli, a fork, a typosquat) could ship a malicious binary; treat any other source as untrusted and stop rather than guess. Use one install path below exactly as written; do not improvise an alternative.
Install gws — first method that fits, in priority order
- Pre-built release binary (preferred). The native executable from the canonical Releases page. Download the asset for the platform target triple AND its
.sha256, verify the checksum, abort on mismatch, then place the binary in ~/.local/bin.
bun add -g @googleworkspace/cli (or npm install -g) — only when node is also on $PATH, since the installed gws is a #!/usr/bin/env node wrapper.
brew install googleworkspace-cli — only if brew already exists.
Install gcloud
- If
brew exists: brew install --cask google-cloud-sdk.
- Otherwise: the official tarball from
dl.google.com (asset name per platform), extracted to ~/google-cloud-sdk, then install.sh --quiet --path-update=false. Download to a temp file and only replace an existing SDK after the download succeeds.
Critical: where the binaries must land
terminal_exec runs each command in zsh -lc, which does not source ~/.zshrc. ~/.local/bin is on the gateway's $PATH (the autostart plist bakes it in; a foreground run inherits it from your login shell), so a binary installed elsewhere must be reachable from there (Homebrew installs already are). Move or symlink the gws release binary into ~/.local/bin. Only symlink gcloud (ln -s ~/google-cloud-sdk/bin/gcloud ~/.local/bin/) — it's a launcher that finds its SDK relative to its real path, so moving it breaks it.
Verify
gws --version
gcloud --version
Do not thrash. When a method fails, move to the next method in the list once, not back to the same dead end. Specifically:
- Do not try to install Homebrew — its installer requires
sudo, which an unattended agent cannot drive.
- Do not probe for an absent brew, open a PTY for an interactive installer, or web-search for an install command.
If every gws method fails (or both gcloud paths fail), STOP and tell the user verbatim what failed plus the single command to run manually for their platform, then wait.
Step 3 — Sign in with gcloud
gcloud auth login
This opens the user's default browser to Google's OAuth consent page. They sign in there. The command returns when the user completes consent.
If gcloud auth list already shows an active account, ask once: "gcloud is signed in as <email>. Use this account?" — proceed on confirmation. Otherwise run gcloud auth login straight through.
Step 4 — Reach a Cloud project with the seven APIs enabled
The goal of this step is a single value, <PROJECT_ID>, that names a Cloud project the active gcloud account owns AND in which the seven Workspace APIs are enabled. Every later step substitutes that string into URLs and gcloud --project= flags.
Invariants for this step and every later gcloud call:
- Pass
--project=<PROJECT_ID> explicitly on every gcloud command that accepts it. Don't rely on gcloud config get-value project; its value can carry over from an unrelated shell or a prior failed run, which is exactly how the wrong-project PERMISSION_DENIED failure happens.
- Treat
<PROJECT_ID> as a value you discover, not a value you write. Read it from the output of projects list or projects create, then thread it through.
- If the user named a specific project earlier in this chat ("use my work project
acme-data-1234"), use that as <PROJECT_ID> and skip straight to 4c. Don't second-guess by listing or creating.
4a. Look for an ACTIVE Gini-managed project first
gcloud projects list --filter="projectId:gini-workspace-* lifecycleState:ACTIVE" --format="value(projectId)"
The filter is on project ID prefix, which we control — not display name, which collides freely. Every project this skill creates has an ID starting with gini-workspace-. If the command returns one or more IDs, pick the first as <PROJECT_ID> and skip ahead to 4d. A prior setup run already provisioned it; making another would just leave orphans.
4b. Otherwise, undelete a recently-deleted one
Google retains deleted project IDs in DELETE_REQUESTED state for ~30 days. During that window the name is reserved (so creating with the same ID fails with ALREADY_EXISTS) but the project can be restored with one call. Check before creating:
gcloud projects list --filter="projectId:gini-workspace-* lifecycleState:DELETE_REQUESTED" --format="value(projectId)"
If this returns one or more IDs, pick the most recent and undelete it:
gcloud projects undelete <PROJECT_ID>
Undelete is a single API call — it does NOT count against the per-minute project-create write quota the way gcloud projects create does, so this also avoids RATE_LIMIT_EXCEEDED during heavy testing. Most enabled APIs survive the undelete, but treat that as best-effort — 4d will re-enable any that didn't.
Skip ahead to 4d once undelete succeeds. (Service Usage takes 30-90 s to re-recognize the project after undelete; 4d's services enable retries internally so it tolerates the gap.)
4c. Otherwise, create a fresh one
The bare ID gini-workspace is almost always globally claimed — don't waste an attempt on it. Derive a deterministic, account-scoped suffix from the gcloud account so re-runs converge instead of accumulating projects:
gcloud config get-value account
Take the part before @, lowercase, and strip to [a-z0-9-]. The project-ID cap is 30 chars; gini-workspace- is 15, so truncate the suffix to 15 chars if needed.
gcloud projects create gini-workspace-<suffix> --name="Gini Workspace"
If even that ID is taken AND no DELETE_REQUESTED match was found in 4b (rare — only when a different user with the same email local-part already claimed it globally), append a 4-char random tiebreaker. <PROJECT_ID> is whichever ID succeeded.
If gcloud projects create errors with RATE_LIMIT_EXCEEDED for cloudresourcemanager.googleapis.com.write_requests, the user has burned through Google's per-account project-create quota (usually from repeated testing). Surface the error verbatim and ask: "Google is rate-limiting project creates; the quota typically clears in ~10 minutes. Want to wait and retry, or do you have an existing Cloud project I can use? Reply with a project ID or 'wait'." Do not loop the create call.
Organization-restricted accounts. If create instead errors with PERMISSION_DENIED and a message like You do not have permission to create projects, the account belongs to an organization (common on managed @company Google Workspace accounts) whose policy reserves project creation for admins. Accepting the Terms of Service will not change this. Surface the error verbatim and ask: "Your Google account can't create Cloud projects — your Workspace admin restricts that. Reply with an existing Cloud project ID I should use, or set Gini up with a personal @gmail.com account instead." Take a project ID as the new <PROJECT_ID> and resume from projects describe in 4d.
4d. Verify access, then enable the APIs
projects describe is the cheapest probe that fails fast if the active account doesn't own <PROJECT_ID> — much better than learning it from services enable's permission error after the model has already committed to a flow.
gcloud projects describe <PROJECT_ID> --format="value(projectId)"
On success, enable the seven APIs (already-enabled ones are no-ops, which is fine when 4a reused an existing project):
gcloud services enable \
gmail.googleapis.com \
calendar-json.googleapis.com \
drive.googleapis.com \
docs.googleapis.com \
sheets.googleapis.com \
forms.googleapis.com \
meet.googleapis.com \
--project=<PROJECT_ID>
Calendar's service ID is calendar-json.googleapis.com (not calendar.googleapis.com).
If either 4d command errors with PERMISSION_DENIED, the active account doesn't own <PROJECT_ID>. Surface the error verbatim and ask the user briefly: "I don't have access to <PROJECT_ID>. Which project should I use?" Take their answer as the new <PROJECT_ID> and re-run from projects describe. Do not fall back to gcloud config get-value project to recover — that's the same stale value that produced the original failure.
Step 5 — Last step: capture OAuth Desktop credentials
This is the only step that requires the user to click in a browser. Send one chat bubble with the two Cloud Console URLs and call request_connector immediately after. The inline form renders below the bubble; the user pastes the Client ID and Client Secret and clicks Save.
Construct the reason string as multi-line markdown with the URLs and click instructions. Substitute <PROJECT_ID> with the actual project id from Step 4 — there is no runtime substitution.
Use this exact format:
**Last step.** Complete the two Cloud Console pages below, then paste the credentials.
**Step 1 — OAuth consent screen** (skip if already configured)
https://console.cloud.google.com/apis/credentials/consent?project=<PROJECT_ID>
- User Type: **External**
- App name: **Gini Workspace**
- Your email for support contact and developer contact
- Save through Scopes (no scopes to add)
- Add yourself as a **Test user**
**Step 2 — Create an OAuth client**
https://console.cloud.google.com/apis/credentials?project=<PROJECT_ID>
- Click **Create Credentials → OAuth client ID**
- Application type: **Desktop app**
- Name it whatever (e.g. "Gini")
- Click **Create**
Then paste the **Client ID** and **Client Secret** below.
Then call:
request_connector {
provider: "google-oauth-desktop",
reason: "<the constructed markdown string above, with <PROJECT_ID> filled in>"
}
Do NOT post a separate chat message before the tool call. Do NOT open <url> for either Console URL — let the user click from the bubble. Don't gate on "reply done" between the two pages — the form submission is what advances the flow.
On Save, the connector is created with env bindings (GOOGLE_WORKSPACE_CLI_CLIENT_ID, GOOGLE_WORKSPACE_CLI_CLIENT_SECRET), and the chat-task resumes at Step 6.
Tagging the account
Before login, ask the user for a short tag for THIS account — e.g. "personal", "work", "school". On first-time setup it's fine to ask "What should I call this account?" The tag labels the account everywhere (it's how Gini will later refer to it and how the user picks which account to use). Tags are unique across accounts.
Step 6 — Sign in with skill_run google-account-login
Now that the google-workspace-oauth connector exists, delegate the login to the google-account-login skill — do NOT run gws auth login from terminal_exec yourself. That skill's script gets the Client ID/Secret injected into its env, mints this account's own config dir, opens the user's browser to the consent screen, waits for them to finish, and registers the tagged account:
skill_run {
skill: "google-account-login",
script: "account-login",
args: { tag: "<the tag the user chose>", services: ["drive","gmail","calendar","docs","sheets","meet","forms"] }
}
The script opens the user's default browser automatically and blocks until they complete consent — sign-in is human-in-the-loop, so don't type their email or password. It returns { ok, id, tag, email, configDir, scopes } on success, or { ok: false, error } on failure. On success, tell the user which account (by email + tag) is now connected, then go to Step 8.
If ok is false, surface the error verbatim and act on it:
"gws never printed the consent URL" or a gws auth login failure → the user may have closed the browser without approving; just call skill_run again (idempotent). If it mentions invalid_client, the stored Client ID/Secret are wrong — re-run request_connector for google-oauth-desktop (Step 5). A redirect_uri mismatch means the OAuth client was created as Web type, not Desktop — re-create as Desktop and re-paste.
"Login did not produce a valid session." → the user didn't finish consent; offer to try again.
Always request every service we enabled APIs for in services, regardless of what the user originally asked. The user enabled APIs for all seven Workspace products in Step 4, and Google's consent screen renders each scope as its own row with per-scope checkboxes (for unverified apps in testing mode) — the user picks which to grant there, not by us pre-filtering the services list. Narrowing services to just "calendar" because the user's first ask was a calendar question silently locks them out of Drive / Gmail / Docs / etc. for the rest of the session.
When the user wants different scopes than the default
Two cases warrant deviating from the all-seven-services default — pass them through the args:
- The user explicitly asks for a narrower or read-only grant ("I only use Gmail, skip the rest" / "give Gini read-only access"). Trust them: pass
services: ["gmail"] and/or readonly: true.
- The user is on a personal
@gmail.com account AND wants the "full" Gmail scope (https://mail.google.com/, which includes permanent delete). The default recommended preset will fail on unverified personal apps; pass the full scope URL(s) via scopes instead, which overrides services.
# Read-only across the user's chosen services
args: { tag: "...", services: ["gmail","drive"], readonly: true }
# Exact per-scope picks (full URLs) — for the rare case the user names a
# specific scope shape service names can't express
args: { tag: "...", scopes: ["https://www.googleapis.com/auth/gmail.readonly","https://www.googleapis.com/auth/drive"] }
services takes service names, not scope strings — services: ["gmail.readonly"] is silently dropped; use scopes with full URLs for that.
Reference table of services name ↔ full scope URL (only useful when the user names a specific scope shape; the default is the full seven-service services list, not anything from this table):
- Gmail —
-s gmail ↔ https://www.googleapis.com/auth/gmail.modify (read + send + reply + label + draft; NOT permanent delete). Narrower: .readonly / .send / .compose.
- Gmail (full, incl. permanent delete) —
--scopes "https://mail.google.com/". No -s shorthand.
- Drive —
-s drive ↔ https://www.googleapis.com/auth/drive (.file, .readonly, .metadata.readonly available as full URLs).
- Calendar —
-s calendar ↔ https://www.googleapis.com/auth/calendar (.events, .readonly, .freebusy available as full URLs).
- Docs —
-s docs ↔ https://www.googleapis.com/auth/documents (.readonly available).
- Sheets —
-s sheets ↔ https://www.googleapis.com/auth/spreadsheets (.readonly available as a full URL).
- Meet —
-s meet ↔ https://www.googleapis.com/auth/meetings.space.created (.readonly available).
- Forms —
-s forms ↔ https://www.googleapis.com/auth/forms.body (.body.readonly, .responses.readonly available).
Never pass --full or the default recommended preset on a personal @gmail.com account — those expand to 80+ scopes including pubsub and cloud-platform, which an unverified app cannot grant. The seven-service list stays under the ~25-scope cap.
Adding another account later
When the user wants to connect an additional Google account (one OAuth client can authorize many accounts), there's no new project, connector, or gcloud work — the existing google-workspace-oauth connector / Cloud project is re-used. Just:
-
Ask the user for a tag for the new account ("What should I call this one?").
-
If the Cloud app is still in testing mode, the new account's email must be a Test user on the OAuth consent screen, or consent will fail. Give them the consent-screen URL to add it (substitute the project id):
https://console.cloud.google.com/apis/credentials/consent?project=<PROJECT_ID>
-
Call skill_run google-account-login with the new tag (NOT adopt):
skill_run {
skill: "google-account-login",
script: "account-login",
args: { tag: "<new tag>", services: ["drive","gmail","calendar","docs","sheets","meet","forms"] }
}
This signs the second account into its own config dir and registers it alongside the first.
Adopting the existing sign-in
Use adopt: true ONLY to IMPORT an already-valid ~/.config/gws session as a tagged account without a fresh login — e.g. the user had a pre-existing gws setup and you just want it on the account list. It never opens the browser, never touches ~/.config/gws's tokens, and fails if that dir's session is not currently live.
skill_run {
skill: "google-account-login",
script: "account-login",
args: { tag: "<tag>", adopt: true }
}
Do NOT use adopt: true to fix an expired default-dir session — adopt skips login, so it can't re-auth. To re-log-in the default dir, pass configDir: "~/.config/gws" (non-adopt) per Step 0's re-auth path. Rule of thumb: adopt = import a valid session; configDir = re-auth / target a specific existing dir with a real login.
Step 7 — Stop the per-call approval prompt (optional)
Every gws call goes through Gini's approval-gated terminal_exec tool. To stop the prompt firing on every invocation, patch the per-instance auto-approve list:
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"patterns": ["gws *"]}' \
http://127.0.0.1:<port>/api/settings/auto-approve
Set $TOKEN from ~/.gini/instances/<instance>/config.json (the apiToken field).
For finer-grained gating, list each product:
{ "patterns": ["gws gmail *", "gws calendar *", "gws drive *", "gws docs *"] }
Step 8 — Smoke test
A read-only call that exercises auth:
gws drive files list --params '{"pageSize": 1}'
If that returns JSON without an auth error, the setup is complete. Resume the user's original ask (read mail, list calendar events, etc.).
Rules
- Walk this skill end-to-end on first-time setup. Do not skip to
request_connector or the google-account-login skill without the install + project + APIs in place. The one exception is the stored-client path (Step 0 (b)): when the google-workspace-oauth connector already exists, skill_run google-account-login alone is the whole job — whether re-authing an expired account or doing the first sign-in for a freshly-stored client. gcloud, project creation, and request_connector are provisioning-only and must not re-run.
- Sign-in is a human-in-the-loop step. Never attempt to type the user's email or password.
gcloud auth login and the google-account-login script both open the default browser — wait for the command (or skill_run) to return.
- Capture credentials through the inline form, not files. Always use
request_connector { provider: "google-oauth-desktop" }. Never ask the user for a path to client_secret.json, never write a JSON file under ~/.config/gws/, and never cat or echo the credentials back into chat.
- Enable all seven Workspace APIs in Step 4 regardless of which product triggered setup. One
gcloud services enable call covers them all; this lets the user pivot to another product later without re-running setup.
- Status messages are action-oriented and ungrouped. Do not list "Installed gws, installed gcloud, signed in, created project, enabled APIs." The user sees a chat bubble per milestone (confirm setup, last-step form, done) — not a retrospective changelog.
- Fail gracefully. If
gcloud errors with PERMISSION_DENIED or ALREADY_EXISTS, surface the error verbatim and ask the user. If an install fails, STOP — do not retry in a loop, hand off to the user with the one-line manual command.
- The
services you pass to google-account-login include every service we enabled APIs for in Step 4, not just the one the user happened to ask about. Google's consent screen renders each scope as its own checkbox row in testing mode — the user picks there. Narrowing services based on the current request silently locks the user out of the other six surfaces; they'd have to re-run setup the next time they want anything else. The only time you narrow is when the user explicitly says so ("read-only," "Gmail only," etc.).
- If the user is in a CI or headless environment, point them at the export flow (
gws auth export --unmasked > credentials.json on a desktop machine, then GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=… on the headless one).
Manual Fallback
If gcloud cannot be installed at all (uncommon — both macOS and Linux have a documented tarball install at https://docs.cloud.google.com/sdk/docs/install-sdk), hand off the Cloud Console flow to the user manually:
- Tell them to open https://console.cloud.google.com/ and create a project named
gini-workspace.
- Enable the seven APIs at https://console.cloud.google.com/apis/library — Gmail, Calendar, Drive, Docs, Sheets, Forms, Meet.
- Then resume from Step 5 (configure OAuth consent, create Desktop OAuth client, paste credentials into the inline form).
For flags not shown here, run gws auth --help and gws --help.