A CLI and MCP server for working with Gmail, Google Drive, Docs, Sheets, Chat, and Calendar from the command line or from AI agents. Built on the mcp-app framework so the same binary works locally (stdio, one human) and as a hosted multi-user service (HTTP, JWT-authenticated).
Status: local stdio is the documented one-human path. Cloud HTTP deployment is supported via gapp (Cloud Run + custom domain + JWT auth via mcp-app); see Cloud deployment below for the abstract walkthrough.
- Python 3.10+ (3.11 recommended).
- A Google Cloud project you can configure. gwsa needs an OAuth
2.0 Client ID — either one you create in the Cloud Console (the
default path, gives you control over billing and scopes) or
gcloud's well-known client (faster setup, billed via
--quota-project). The Authenticating with Google section below walks through the Console steps. - For the gcloud variant: the
gcloudCLI installed and authenticated (gcloud auth application-default login).
Workspace (corp) accounts: if your Google identity is part of a Workspace org with strict OAuth policies (allowlisted clients, sensitive-scope review, Context-Aware Access), the Cloud-Console OAuth-client path may not work for that identity. See Workspace org constraints before spending time creating a client.
pipx install git+https://github.com/echomodel/gworkspace-access.git@v0.32.0Installs three commands:
| Command | What it does |
|---|---|
gwsa |
Google Workspace domain operations (mail, drive, docs, sheets, chat). |
gwsa-mcp |
MCP server for AI assistant integration. |
gwsa-admin |
Setup, credentials, user/account management. |
This is the headline path. If you have one Google identity you want to access, do exactly this.
Pick one of the two paths.
In the Google Cloud Console:
- Select or create a project. This project becomes the billing
home for every API call gwsa makes for this account; no
--quota-projectis needed for tokens issued by this client. - Enable APIs you'll use: Gmail API, Google Drive API, Google
Docs API, Sheets API, Google Calendar API, and (if you want chat
tools) Google Chat API and People API.
APIs & Services → Enabled APIs → ENABLE APIS AND SERVICES. - Configure the OAuth consent screen (only on first OAuth client in the project): set publishing status to Testing, add your Google email under Test users. This lets your own account consent without app verification. Submit nothing.
- Create the OAuth client.
APIs & Services → Credentials → Create credentials → OAuth client ID → Application type: Desktop app. Download the JSON; this is yourclient_secrets.json. Put it anywhere on disk — gwsa reads it once, on demand.
If you don't want to manage a Cloud Console OAuth client:
gcloud auth application-default login
gcloud auth application-default set-quota-project YOUR_GCP_PROJECTThis produces a token at
~/.config/gcloud/application_default_credentials.json issued by
gcloud's well-known OAuth client. Skip step 3 below and jump to the
gcloud variant in step 4 — you don't run
acquire-token, you hand the existing blob directly to
accounts add.
gwsa-admin connect localThis writes a one-line setup file so subsequent admin commands know to read and write the local user store (not a remote URL).
gwsa-admin acquire-token --client-secrets /path/to/client_secrets.json |
gwsa-admin accounts add personal --email me@example.com --token=-acquire-token opens a browser, runs the OAuth consent flow, and writes
the resulting token JSON to stdout. accounts add reads it from stdin
and stores it. On a fresh install the user record is auto-created from
--email and the new account becomes the default — no separate "users
add" ceremony.
gwsa-admin accounts list
gwsa mail search "newer_than:1d"That's it. Everything below is optional — adding more accounts, registering the MCP server with an AI client, or operating on the cloud variant.
If you'd rather use a token issued by gcloud auth application-default login:
gcloud auth application-default login
gwsa-admin accounts add gcloud-account \
--email me@example.com \
--token=@~/.config/gcloud/application_default_credentials.json \
--quota-project YOUR_GCP_PROJECT--quota-project is required because gcloud's well-known OAuth client
has no host project of its own; API calls need a billing project. If
you ran gcloud auth application-default set-quota-project first, the
project is already in the blob and --quota-project becomes optional.
A gwsa user is one human (you). Inside your user record is a list
of accounts — one entry per Google identity you own (personal,
work, etc.). One of them is marked as default_account; gwsa CLI calls
and MCP tools use it implicitly.
Add a second account the same way as the first, plus a name to tell them apart later:
gwsa-admin acquire-token --client-secrets /path/to/work.json |
gwsa-admin accounts add work --email me@example.org --token=-Switch which account is the default:
gwsa-admin accounts use work # now 'work' is implicitInspect, remove, or override:
gwsa-admin accounts list # see all accounts, marked with (default)
gwsa-admin accounts get work # detail one
gwsa-admin accounts remove personalFor per-call override on the gwsa CLI, pass the --account flag on the
top-level command (e.g. gwsa --account work mail search "..."). It
takes an account name or email and overrides the user's
default_account for that invocation; omit it to use the default.
Single-user installs (one user in the local store) need nothing extra — every command resolves credentials through that user's default account:
gwsa mail search "from:bob after:2026-01-01"
gwsa drive list
gwsa docs read DOC_ID
gwsa sheets tail SPREADSHEET_ID -n 5
gwsa chat spaces list
gwsa calendar events --time-min 2026-06-01T00:00:00ZIf the local store has multiple users, pass --user once on the
top-level command:
gwsa --user me@example.com mail search "newer_than:1d"The --user flag selects the gwsa user; the user's default_account
selects which Google identity inside that user's profile to use. To
override the account for a single invocation, add --account <name-or-email> on the top-level command (e.g.
gwsa --account work drive upload ...).
Calendar tools cover listing calendars, listing/searching events, and creating, updating, and deleting events:
gwsa calendar calendars
gwsa calendar events --time-min 2026-06-01T00:00:00Z --time-max 2026-07-01T00:00:00Z
gwsa calendar create "Team sync" 2026-06-02T09:00:00-05:00 2026-06-02T09:30:00-05:00
gwsa calendar create "Conference" 2026-06-10 2026-06-12 --all-day
gwsa calendar update EVENT_ID --availability free
gwsa calendar delete EVENT_IDAn event's availability is its Free/Busy state, mapped to the
Calendar API transparency field. Pass --availability free|busy
(CLI) or availability="free"|"busy" (MCP) on create and update.
Defaults mirror the Google Calendar web UI, with one deliberate asymmetry:
- All-day events default to Free. A multi-day "Conference" or an
"Out of office" marker won't block your availability unless you say
--availability busy. - Timed events default to Busy — the Calendar API's own default.
The returned event always carries an explicit, normalized
transparency plus an availability alias (free/busy), even
though the raw API omits transparency when it is the default
opaque. That means you can always confirm what was set without
guessing.
update never changes Free/Busy unless you pass --availability — it
only touches the fields you give it, so it won't silently flip an
existing event.
New scopes — re-authenticate. Calendar uses the
calendar.readonlyandcalendar.eventsOAuth scopes. If you configured gwsa before Calendar support landed, re-runacquire-tokenandaccounts add(see Rotating tokens) so your stored token carries the Calendar scopes.
Drive tools follow the Drive API: drive_create_file is files.create,
drive_update_file is files.update (CLI: gwsa drive upload /
gwsa drive update).
gwsa drive upload notes.md # store as-is
gwsa drive upload Plan.md --mime-type application/vnd.google-apps.document # → formatted Doc
gwsa drive upload table.csv --mime-type application/vnd.google-apps.spreadsheet # → Sheet
gwsa drive update FILE_ID --name "New name" # rename
gwsa drive update FILE_ID --folder-id FOLDER_ID # move
gwsa drive update FILE_ID v2.pdf --name "v2.pdf" --folder-id FOLDER_ID # all at once- Convert on upload by giving the Drive file a Google type
(
--mime-type/ MCPmime_type): Markdown, HTML, DOCX and TXT become Docs; CSV and XLSX become Sheets; PPTX becomes Slides. Without it, the file is stored as uploaded. The upload'sContent-Typecomes from the file name's extension, or--content-type/content_type. - Rename, move, and replace content are one operation (
files.update): pass any combination of a new name, a destination folder, and new content.
Moving bytes of any size. The network-exposed tools never touch the server's filesystem; reading or writing a local path lives in stdio-only companion tools.
- Small files travel inline in the tool call / response
(
drive_create_file/drive_update_filewithcontent_base64). Small text files (.md,.yaml,.csv,.json, …) download as readable text; binary files as base64. - Hosted (HTTP) server: a large upload — call
drive_create_file(ordrive_update_file) withupload_url=trueto get a direct-to-Google upload URL, then send the file to it from a shell (curl -T); the bytes never pass through the server. This needs a shell with internet access (works in Claude Code; not in shell-less chat apps or sandboxes that blockgoogleapis.com). A large download —drive_downloadreturns the file's Drive download link (open it in a browser signed in to that account). No size cap, no server proxy, no extra credentials. - Local stdio server (shares your filesystem): use the stdio-only tools
drive_create_file_local/drive_update_file_local(passlocal_path=) anddrive_download_to_path(passsave_to=) — read/written straight to disk, any size.
The host-path tools are stdio-only by design (@mcp_transport("stdio")):
over HTTP the server is multi-tenant, so reading or writing a caller-named
server path would be an arbitrary server-file read/write. They are never
registered — nor advertised — on the HTTP surface. There are no extra HTTP
endpoints and no separate auth.
gwsa drive can use an uploaded file's Drive revision history as a
lightweight, server-side version store — list prior versions, fetch the
content of any past version to diff, and pin milestones so they are
never auto-pruned:
gwsa drive revisions list FILE_ID
gwsa drive revisions get FILE_ID REVISION_ID # streams content to stdout
gwsa drive revisions get FILE_ID REVISION_ID --out v1.json
gwsa drive revisions match FILE_ID LOCAL_PATH # which revision == this local file?
gwsa drive revisions match FILE_ID LOCAL_PATH --pin # ...and pin it if found
gwsa drive revisions keep FILE_ID REVISION_ID # pin (keepForever)
gwsa drive revisions unkeep FILE_ID REVISION_ID # unpinrevisions match answers "is this exact local file already backed up as
a revision, and which one?" — it hashes the local file and finds the
revision with the same md5Checksum, no download or JSON-parsing by the
caller. Its exit code is a backup check: 0 if a matching revision
is found, 1 if not, 2 on error (e.g. a native Google file). --pin
pins the matched revision (keepForever) in the same call — pair it with
content written outside the CLI (a normal save into a Drive-synced
folder) to confirm the upload landed and pin the exact revision by hash,
without relying on "the latest revision" or upload timing.
A new file's revisions only stack when you update the same file by id
— drive upload always creates a new file. So the version-store loop
is: upload once (keep the returned id), then update <id> for each
new version. Pin a milestone in the same step with --keep:
gwsa drive upload data.json # → {"id": "FILE_ID", ...} (v1)
gwsa drive update FILE_ID data.json # v2
gwsa drive update FILE_ID data.json --keep # v3, pinned in one call
gwsa drive upload data.json --keep # pin the initial revision too--keep sets keepForever on the resulting revision atomically (via the
Drive API's keepRevisionForever), so you don't need a separate
revisions keep call.
Behavior to know:
- Content is retrievable only for uploaded (non-native) files —
JSON, CSV, DOCX, PDF, images, etc. Native Google files
(Docs/Sheets/Slides) can be listed but their historical content is
not exportable;
revisions getsurfaces a clear error for them. - Auto-pruning. Drive prunes non-pinned revisions roughly after 100
versions or 30 days.
revisions keepsetskeepForeverso a milestone persists. Drive caps pinned revisions at ~200 per file. - Pinning an old revision is one-way. Drive only lets you toggle
keepForeverboth ways on the head (current) revision. Once a non-head (older) revision is pinned, the API refuses to un-pin it —revisions unkeepreturns a clear error in that case. Pin older milestones deliberately. - No revision name. The API has no writable name/description on a revision — only the pin flag, timestamps, checksums, and size. Treat any human "commit message" as something to put inside the file content, not on the revision.
The same operations are exposed as MCP tools: drive_list_revisions,
drive_get_revision, drive_keep_revision, drive_unkeep_revision.
(match is CLI-only — it's a local-file operation that doesn't map
cleanly to a hosted MCP tool, since a remote server can't read the
agent's filesystem and shipping the whole file inline just to hash it
defeats the purpose.)
Attach custom key/value metadata to any Drive file or folder so it can be found by tag instead of a hardcoded ID — useful when a skill or script needs to relocate its backing file across machines:
# Tag once (CLI, namespaced public key)
gwsa drive set-properties FILE_ID --prop myapp=expense-trackerDiscover the file later with the drive_search MCP tool (or any Drive
files.list query) — tag lookup has no dedicated CLI command, it's a
normal Drive query:
properties has { key='myapp' and value='expense-tracker' }
- Merge per key, one call. A passed key is added/updated;
--prop key=(empty value) deletes it; keys you don't pass are untouched — it never clobbers other apps' tags, and needs no read-before-write. properties(public) vsappProperties(--app-prop). Public properties are visible to any app with file access and share one namespace (so namespace your keys).appPropertiesare private to the OAuth client that wrote them — good for secrecy, but invisible to other clients, so a cloud deployment and a local CLI won't see each other's. For cross-environment discovery, use publicproperties.- Tags are API-only: they don't appear in the Drive/Docs/Sheets UI and don't travel with a downloaded copy of the file.
MCP tool: drive_set_properties; discovery via drive_search.
Sheets support covers create / list / read / write, shaped around the Sheets API's natural grain — append-at-bottom logs:
gwsa sheets create "Workout Log" --folder-id FOLDER_ID --sheet-title Log
gwsa sheets append SPREADSHEET_ID '["2026-06-12", 5, "felt great"]' --range "Log!A1"
gwsa sheets tail SPREADSHEET_ID -n 7 --sheet Log
gwsa sheets read SPREADSHEET_ID "Log!A1:C10"
gwsa sheets update-cell SPREADSHEET_ID "Log!C5" "updated"
gwsa sheets info SPREADSHEET_IDappendis the write primitive for logs — one atomic call, no read-before-write; the API appends after the last data row.tailreads the last N rows without loading the sheet. The API has no "last N rows" primitive, sotailprobes the anchor column (defaultA) to find the data extent, then range-reads exactly the trailing rows. Payloads stay small no matter how large a log grows. It also reports the row numbers it read, so a follow-upupdatecan target a specific recent row (the read-modify-write "upsert the latest row" pattern).tailalso pages backwards (newest → oldest). The response'sstart_rowis a cursor: pass it back asbefore_row(--before-rowon the CLI) to get the N next-older rows, and repeat whilehas_moreis true. Cursor pages skip the extent probe — each is a single bounded read of exactly N rows. N counts rows (entries), not calendar days, so sparse logs and multiple-entries-per-day logs page the same way.- Writes default to
USER_ENTERED— dates, times, and numbers parse as if typed in the UI. (update-cellkeeps its historicalRAWbehavior;appendtakes--rawto opt out of parsing.)
The same operations are exposed as MCP tools: sheets_create,
sheets_list, sheets_get_metadata, sheets_read,
sheets_read_tail, sheets_update, sheets_append. Creating
directly into a Drive folder is supported via folder_id (resolve
a path with drive_find_folder); relocating later is drive_update_file with folder_id.
Structural changes go through the Sheets API's batchUpdate
primitive — exposed raw, plus convenience wrappers for the common
cases:
gwsa sheets add-tab SPREADSHEET_ID Inventory
gwsa sheets insert-rows SPREADSHEET_ID 5 --count 2 --sheet Log
gwsa sheets delete-rows SPREADSHEET_ID 5 --sheet Log
gwsa sheets set-metadata SPREADSHEET_ID role inventory --sheet Inventory
gwsa sheets find-metadata SPREADSHEET_ID --key role --value inventory
gwsa sheets batch-update SPREADSHEET_ID -r '[{"updateSheetProperties": {"properties": {"sheetId": 0, "gridProperties": {"frozenRowCount": 1}}, "fields": "gridProperties.frozenRowCount"}}]'- Row numbers are 1-based, as in the Sheets UI and the row
numbers
tailreturns. Insert/delete shift the surrounding rows in one call — no rewriting of the data below. - Developer metadata makes tabs rename-proof. Tag a tab (or a
row, a column, or the whole spreadsheet) with a key/value, then
find it by that tag instead of by title. Row and column tags move
with their row/column as rows are inserted or deleted. Tags
default to
DOCUMENTvisibility so any client with access to the file can find them — the same cross-client reasoning as public Driveproperties. Tags don't appear in the Sheets UI. batch-updateis a faithful pass-through: requests are sent as-is, the batch is atomic, and the raw response (per-requestreplies) is returned. Requests address tabs by numericsheetId(seeinfo), with 0-based, end-exclusive indices.- Deleting a tab is gated. A batch containing
deleteSheetis rejected unless--allow-destructive(MCP:allow_destructive=true) is passed. There are deliberately no rename-tab or delete-tab wrappers — renaming is harmless once lookups go through metadata, and deleting stays behind the gate.
MCP tools: sheets_batch_update, sheets_add_tab,
sheets_insert_rows, sheets_delete_rows, sheets_set_metadata,
sheets_find_by_metadata.
Docs are read two ways and written one way.
Reading to understand uses Google's own export — what File → Download produces — so every tab is included and headings, lists, tables, and people/date chips are rendered:
gwsa docs read DOC_ID # Markdown export (default)
gwsa docs read DOC_ID --format text # plain-text exportReading to edit uses the document structure, where every position is Google's own index. The position map prints each paragraph with its exact range; non-text items (chips, images, table/row/cell starts, page and section breaks) each occupy one index and show as markers:
gwsa docs read DOC_ID --format map
# 1-14 [HEADING_1] Plan heading⏎
# 40-46 [list L0] Alpha⏎
# 64-84 Meeting with ⟦person⟧ on ⟦date⟧⏎
# 101-107 [cell 0,0] Fruit⏎
gwsa docs find DOC_ID "Plan heading" # exact ranges of every occurrenceWriting goes through one path, the Docs API's batchUpdate, with
requests passed to Google unchanged — so anything the API can author
(headings, nested lists, indentation, fonts, colors, links, tables,
images, chips, named ranges, tabs) is available. gwsa adds checks
around it:
- Revision lock. Every write passes the
revision_idof the read its positions came from (docs read --format mapanddocs findprint it; every write returns the next one). If the document changed since, nothing is written. - Expectations. Every request that addresses a position states what
is there:
{"text": "Plan heading"}for a range, or{"element": "paragraph"}/{"element": "table"}when the range is exactly one whole paragraph or table;{"before": ...}/{"after": ...}for a point. Text copied from the map may keep its⏎. Requests run in order, as in the API; gwsa replays text inserts and deletes, chip and image inserts, and bullet creation exactly, and checks each expectation against the document as it will be when that request runs. If any expectation does not match, nothing is written and the response states what is actually there — a miscalculated position is refused instead of landing mid-word. - Dry run.
--dry-run(MCP:dry_run=true) runs every check and returns the predicted change without writing. - Change report. After writing, every changed paragraph is returned before and after, with its new ranges.
gwsa docs read DOC_ID --format map # prints "# revision REV" and the ranges
gwsa docs batch-update DOC_ID --required-revision-id REV \
-r '[{"deleteContentRange": {"range": {"startIndex": 58, "endIndex": 63}}},
{"insertText": {"location": {"index": 58}, "text": "Gamma ray"}}]' \
-e '[{"text": "Gamma"}, {"after": "\n"}]'
gwsa docs batch-update DOC_ID --required-revision-id REV --dry-run \
-r '[{"deleteContentRange": {"range": {"startIndex": 98, "endIndex": 125}}}]' \
-e '[{"element": "table"}]'To produce a document that differs from an original in specific ways,
copy it (gwsa drive copy FILE_ID --name ... / drive_copy) — Google
copies it with full formatting — then edit only those parts of the copy.
MCP tools: read_doc (formats content, markdown, text, map,
raw; tab_id for map/raw), find_in_doc, batch_update_doc,
drive_copy, plus list_docs and create_doc. The batch_update_doc
description carries the full working model and recipes for agents.
gwsa-mcp is a stdio MCP server exposing 61 tools across mail,
docs, drive, sheets, chat, calendar, and account discovery (58 over
HTTP, which omits the three stdio-only host-path Drive tools).
Tools are discovered by mcp-app from
gwsa.mcp.tools.{accounts,mail,docs,drive,sheets,chat,calendar}.
Every Google-touching MCP tool accepts an optional account
argument — the account name (e.g. "work") or its Google
email (e.g. "me@example.org") — so an AI client can pick a
specific account per call when the user has more than one. Omit
account to use the user's default_account, or the sole
account when only one is configured. The list_google_accounts
tool exposes the names and emails the agent should pass.
forward_email(message_id, to, note=None, html_note=None, cc=None, bcc=None, as_draft=True) forwards a message rebuilt from its full
source MIME, preserving:
- every regular attachment, byte-for-byte,
- every inline image with its original Content-ID, so the quoted
HTML's
cid:references (signature logos, embedded charts) still render, - both the HTML and plain-text body alternatives,
then prepends your note / html_note. A forward starts a new thread
and defaults to creating a draft (as_draft=True) so the user can
review before sending.
reply_email(message_id, body=None, html_body=None, reply_all=True, to=None, cc=None, bcc=None, as_draft=True, include_quote=True)
reconstructs a threaded reply (threadId, In-Reply-To, chained
References), defaults to Reply-All (reply_all=True, excluding the
active account's own address, or targeting the original To/Cc when
following up on a self-sent message), and defaults to creating a draft
(as_draft=True). When the quoted tail contains inline cid: images,
reply_email re-attaches the matching Content-ID parts so they still
render (it does not re-carry the original's file attachments — only the
inline images the quoted body points at).
read_email_structure(message_id) exposes the full per-part MIME
structure behind a message — each part's mime_type, content_id,
disposition (inline vs attachment), filename, size, and whether
the HTML body references it via cid: — for callers that need the
structure rather than the decoded body that read_email returns.
The stdio entry point takes a --user KEY selector identifying
which registered local user it should run as. The key is an opaque
local-store handle — not a Google email. The Google account
emails live on each GoogleAccount inside the user's profile and
never need to surface in MCP registrations.
gwsa-admin migrate creates a single user keyed local by default.
Use that key in all registrations below.
Register with your client:
Claude Code:
claude mcp add --scope user gwsa -- gwsa-mcp stdio --user local
claude mcp list # verifyAntigravity CLI (agy):
agy mcp add gwsa -- gwsa-mcp stdio --user local
agy mcp list # verifyClaude.ai web (custom connector): requires the cloud HTTP variant. After cloud deploy (see below), generate a one-shot connector URL with a long-lived token via:
gwsa-admin register --user me@example.com --client claude.aiPaste the resulting https://<your-cloud-domain>/?token=<jwt>
URL into claude.ai → Settings → Connectors → Add custom
connector. Local stdio doesn't apply to this client.
Client-specific quirks, troubleshooting, and detailed transport options live in Claude Code Configuration and Antigravity CLI Setup. The combined story across all clients is in MCP Server Setup.
| Path | Purpose |
|---|---|
~/.local/share/gwsa/users/<email>/auth.json |
Per-user auth record. |
~/.local/share/gwsa/users/<email>/profile.json |
Per-user profile (accounts list). |
~/.config/gwsa/setup.json |
connect local / connect <url> config. |
~/.config/gworkspace-access/profiles/<name>/ |
Legacy vault (pre-mcp-app). Read-only after gwsa-admin migrate; delete manually when satisfied. |
class GoogleAccount(BaseModel):
name: str # 'personal', 'work', ...
email: str # Google account email
token: dict # authorized_user blob (refresh_token, client_id, scopes, ...)
quota_project: str | None # required for gcloud-issued tokens; optional otherwise
class Profile(BaseModel):
accounts: list[GoogleAccount]
default_account: str | Nonegwsa-admin connect local
gwsa-admin connect <url> --signing-key <key> # hosted instance
gwsa-admin connect remote # switch back to the saved hosted instance
gwsa-admin acquire-token --client-secrets PATH [--scopes ...] [--out FILE]
gwsa-admin accounts add NAME --email EMAIL --token=<-|@FILE|JSON> [--quota-project ID] [--user KEY]
gwsa-admin accounts list [--user KEY]
gwsa-admin accounts get NAME [--user KEY] [--show-token]
gwsa-admin accounts remove NAME [--user KEY]
gwsa-admin accounts use NAME [--user KEY]
gwsa-admin migrate [--user-key KEY] [--skip-broken] [--dry-run] # one-shot legacy → mcp-app
gwsa-admin users list
gwsa-admin users revoke KEY
gwsa-admin --help lists every command. Each subcommand has its own
--help.
If you used gwsa before this branch (profiles in
~/.config/gworkspace-access/profiles/), run:
gwsa-admin migrate --dry-run # preview
gwsa-admin migrate # do it
gwsa-admin accounts list # verify
rm -rf ~/.config/gworkspace-access/profiles # delete when satisfiedEach legacy profile becomes one GoogleAccount on a single user
record (one human, many accounts). The active legacy profile becomes
default_account. The legacy directory is left in place until you
remove it.
If the Google identity you want to register is a corporate Workspace
account (not a free @gmail.com consumer account), the
Console-OAuth-client path (Path A above) may be blocked by your
org's policies. The common failure modes:
- OAuth client allowlist. Many corp orgs only allow IT-blessed app IDs to request user consent. Your personal-project OAuth client gets refused at the consent screen.
- Sensitive-scope review. Gmail/Drive/Calendar scopes are classified "sensitive" by Google; many orgs require admin allowlisting per app even when the client itself is permitted.
- Context-Aware Access. Policies that gate auth flows on device posture or network can refuse the flow entirely.
What actually works varies by org:
- gcloud variant (Path B) sometimes routes around the OAuth allowlist because gcloud uses Google's own well-known client. Context-Aware Access can still block it.
- You may have one GCP project for your personal identity and zero for your corp identity. Phase-2 cloud deployments shouldn't assume "the user's GCP project" as a singular thing.
This is one of the reasons each account on a gwsa user's profile
carries its own quota_project and was acquired with its own
OAuth client config — different identities may have to use
different projects, not just prefer to. See §5 of
Cloud Multi-User Architecture for the
phase-2 design implications.
OAuth refresh tokens can die without warning. The most common
cause on personal-use installs is the Testing-mode 7-day
expiry: if the OAuth client whose client_secrets.json you
used is set to "Testing" in the Google Cloud Console, every
refresh token it issues expires exactly 7 days after consent,
regardless of use.
Symptom: any gwsa CLI or MCP tool call fails with
invalid_grant: Bad Request from google-auth's token refresh.
Find the project number — it's the leading digits of any
client_id in your stored token, and also of the
installed.client_id field in your client_secrets.json.
Then open:
https://console.cloud.google.com/auth/audience?project=<project-number>
If the page shows Publishing status: Testing, you're on the 7-day clock. If it shows In production, refresh tokens last indefinitely (or until manually revoked) — the failure is from something else (the client was deleted, the secret was rotated, or the user revoked access at myaccount.google.com).
In the audience page above, click Publish app. New refresh tokens minted after that won't expire from the 7-day rule. Apps that request sensitive scopes (Gmail, Drive, Docs, Sheets) can publish without going through Google's verification; users see an "unverified app" warning at consent but the token works fine for personal use.
Tokens that already died from the 7-day clock stay dead — only new tokens minted post-publish benefit. You still need to re-acquire.
Single-user installs auto-resolve to the only user in the store,
so --user is unnecessary in every step below. Add
--user <key> only if you have multiple users locally.
gwsa-admin accounts remove personal
gwsa-admin acquire-token \
--client-secrets ~/.config/gworkspace-access/client_secrets.json \
--out /tmp/gwsa-token.json
gwsa-admin accounts add personal --email you@example.com \
--token=@/tmp/gwsa-token.json
rm /tmp/gwsa-token.jsonacquire-token opens a browser, you consent, the fresh token
JSON lands in /tmp/gwsa-token.json, and accounts add reads it
from there. The pipe form (acquire-token | accounts add --token=-) is equivalent and supported as of version 0.10.1;
earlier versions had a stdout-flush bug that could swallow the
token mid-pipe.
Verify with any read:
gwsa mail search "newer_than:1d" --max-results 1Rare. If you genuinely have multiple humans sharing one workstation
and one gwsa install, add --user <key> to every gwsa-admin
command that's not unambiguous. The cloud variant
is the right answer when multi-user is the actual deployment shape.
gwsa runs as a hosted multi-user HTTP service on Google Cloud Run via gapp. The same binary that serves stdio for a single human serves HTTP for many — mcp-app handles transport, JWT auth, admin REST, and GCS-backed user state automatically.
- Cloud Run service runs
gwsa(the mcp-app App entry point). gapp builds the container, deploys with Terraform, binds a custom domain, and provisions the SSL certificate. - GCS bucket (managed by gapp) backs the per-user
profiles directory at
/mnt/data/usersvia GCS FUSE. - GCP Secret Manager holds the JWT signing key as a
gapp-managed secret; gapp injects it into Cloud Run as the
SIGNING_KEYenv var. - Custom domain (optional) is bound at deploy time via
gapp's
domain:field; the.run.appURL keeps working in parallel. - Auth model: every MCP and admin request carries a JWT
signed by the deployment's signing key.
gwsa-adminmints per-user tokens from the same key;gwsa-admin registerpackages them into client-specific registration commands.
From a clone of this repo:
pipx install gapp
gapp init # writes gapp.yaml (already in repo)
gapp setup <your-gcp-project-id> # one-time
gapp deploy # build + terraform apply
gapp status # service URLThe committed gapp.yaml declares:
public: true(mcp-app handles its own JWT auth)- a generated
SIGNING_KEYsecret APP_USERS_PATH={{SOLUTION_DATA_PATH}}/users(GCS-backed)
A custom domain is opt-in — either edit gapp.yaml's
domain: before gapp deploy, or do it via CI scalar
overlay (see CONTRIBUTING.md). Leaving it absent serves
on the assigned .run.app URL only.
Once gapp status reports the URL:
# Fetch signing key (run from this repo so gapp resolves
# the solution from the working directory)
gwsa-admin connect https://<your-cloud-domain> \
--signing-key "$(gapp secrets get signing-key --plaintext)"
# Probe the deployment end-to-end
gwsa-admin probe
# Add a user and seed their first Google account
gwsa-admin acquire-token \
--client-secrets /path/to/client_secrets.json |
gwsa-admin users add me@example.com --token=-
# Generate client registration commands
gwsa-admin register --user me@example.comgwsa-admin register emits Claude Code, Antigravity CLI
(agy), and Claude.ai registration commands/URLs scoped to
that user (--client claude|agy|claude.ai limits the output).
For a multi-account human, add more accounts to the same
user via gwsa-admin accounts add ... and pass account=...
on per-tool-call MCP invocations to select between them.
gapp supports GitHub Actions deploys via a reusable
workflow. Pin your deployment workflow to a released gapp
tag and pass solution-repo, WIF identity, and any scalar
overlays (e.g. domain) as inputs. See the gapp deploy
skill (plugin:gapp:deploy) for the full pattern.
Cloud Multi-User Architecture captures the locked design — user/profile/account model, credential resolution flow, and open Phase 2 questions (refresh-token rotation cadence, audit logging, BYOC vs. service-OAuth-client tradeoffs).
"No active profile" / "User not found": you haven't run
gwsa-admin connect local yet, or you haven't added any accounts.
"This token was issued by gcloud's well-known OAuth client...":
pass --quota-project YOUR_GCP_PROJECT (or run gcloud auth application-default set-quota-project and re-export the blob).
Token refresh errors during a CLI call (invalid_grant: Bad Request): the stored refresh_token died — revoked, expired, or
caught by the Testing-mode 7-day clock. See
Rotating tokens for
diagnosis (including how to check the OAuth client's publishing
status) and the exact recovery sequence.
Old gwsa profiles / gwsa client / gwsa setup commands gone:
intentional. See Upgrading from the legacy vault.
Cloud Multi-User Architecture is the canonical design doc — covers the user/profile/account model, the mcp-app framework adoption, the credential resolution flow, and the phased migration plan from the legacy per-profile vault.
See Release Notes for per-version highlights, breaking changes, and migration steps.
See CONTRIBUTING.md for development setup, repo layout, architecture rules (SDK-first), test conventions, and the version-management workflow.