Multiple Google accounts, one CLI, optimal for Agents.
A tiny bash wrapper around gws
(Google's officially unofficial Workspace CLI). Every call must name an
account — so an agent can never send a personal email from your work
address by accident.
npm install -g @pa1nd/gwxThat pulls in gws (the Google Workspace CLI) as a dependency, so there's
nothing else to set up — and it puts the gwx command on your PATH. Update
with npm update -g @pa1nd/gwx, remove with npm uninstall -g @pa1nd/gwx.
Requires Node 18+ (macOS / Linux).
jq is required in practice. Without it the scope picker cannot run and
multi-account fan-out degrades to a stub envelope with a different shape, so
the documented jq pipelines stop working. brew install jq /
apt install jq. git is needed only for gwx skills install and
gwx learn, and timeout (GNU coreutils) only if you set GWX_TIMEOUT —
gwx warns when you ask for a timeout it cannot enforce.
Or via the curl installer (no npm)
curl -fsSL https://raw.githubusercontent.com/pA1nD/gwx/main/install.sh | bashSets everything up: installs gwx, adds gws if you don't have it, and
prints the next step. Re-run anytime to update.
See everything at once. Fan out across every account in parallel:
gwx all calendar +agenda # today's events, every account
gwx work,personal gmail +triage # unread inbox, work + personalOutput is one JSON line per account. To pretty-print grouped per account:
gwx all calendar +agenda | jq -r '"=== \(.account) ===\n\(.stdout)"'Reads on multiple accounts fan out — requests run in parallel. Writes
(e.g. send, create, delete) refuse to run without a single named
account, so you can't accidentally mass-mail from every inbox at once.
Hand it to an agent. Ask Claude things like:
> Anything urgent across my inboxes right now? Flag, don't reply.
● I'll scan every inbox in parallel and flag anything that looks urgent.
● Bash(gwx all gmail +triage)
⎿ work → 312 unread, 3 marked urgent
personal → 87 unread, 1 marked urgent
side → 19 unread, all spam
● Bash(gwx work gmail users messages modify ...) ×3
● Bash(gwx personal gmail users messages modify ...) ×1
⎿ 4 threads flagged
● Flagged 4 threads. No replies sent.
• work: invite — "30 min sync" tomorrow 9am, no agenda (HR + your manager)
• work: legal — subject "see attached", 9 PDFs, empty body
• work: AWS billing — monthly spend up 340% (MTD $18,400)
• personal: mom — "haven't heard from you in a while" (sent Sunday, it's Friday)
Or, multi-step:
> What should I know about for my next meeting?
● I'll find your next meeting and check recent threads with the attendees.
● Bash(gwx all calendar +agenda)
⎿ work → 14:00 "Q3 review w/ Acme" (alice@acme.com, bob@acme.com)
personal → 18:30 "Dinner w/ Sam"
side → (nothing today)
● Bash(gwx all gmail users messages list --params '{"q":"from:(alice@acme.com OR bob@acme.com)"}')
⎿ work → 12 threads, latest 2 days ago
personal → 0 threads
side → 0 threads
● Q3 review with Acme at 14:00 — TL;DR:
• Alice pushed back on enterprise pricing yesterday — wants 15% or annual.
• Bob asked for the churn deck (still owed).
• Open from last time: SSO rollout date, contract redlines.
And there's a deeper bench: ~95 ready-made workflow skills — +triage,
+meeting-prep, drive audits, role-based personas — drop into the
agent's project with gwx skills install. More on that below.
You need ONE OAuth client (it can be shared across all your accounts, or you
can create one per account — gwx init will ask).
Briefly:
- Sign in to console.cloud.google.com with the account that should own the project. (For Workspace-only setups, pick the account where you're admin.)
- Create a project (e.g.,
gwx-oauth-client). - Enable APIs (☰ → APIs & Services → Library): Gmail API, Google Drive API, Google Calendar API, Google Sheets API, Google Docs API, Google People API.
- Configure OAuth consent screen (APIs & Services → OAuth consent screen):
- User Type: External
- Add your email(s) as Test users
- Create OAuth client (APIs & Services → Credentials → + CREATE CREDENTIALS):
- Application type: Desktop app
- Copy the Client ID and Client secret
- (Workspace admins) Trust the app (admin.google.com → Security → Access
and data control → API controls → Manage App Access →
- Configure new app → OAuth App Name or Client ID → paste your Client ID → Trusted → Continue → Finish). Skip if your org isn't admin-managed by you.
Run gwx init <name> once per account. The first time you'll be asked for
your OAuth client; subsequent calls offer to reuse the existing client (or
provide a new one).
gwx init work
# Prompts for client_id and client_secret (paste from step 5 above)
gwx init personal
# Existing OAuth clients found:
# 1) work
# n) provide a new client
# → Hit enter (default 1) to reuse work's client
gwx init sidegwx login work
# Opens browser → sign in as work account → click through unverified-app warning
gwx login personal
gwx login sideEach login opens a scope picker first — an arrow-key multi-select seeded
from a baked catalog and cross-checked against Google's live discovery API.
Scopes Google has added since the catalog was written appear tagged NEW;
catalog scopes that no longer exist render greyed as retired. Eight core scopes (Drive, Gmail, Calendar, Sheets, Docs, Slides, Tasks,
Pub/Sub) are pre-checked; the ninth, Cloud Platform, is listed but left off,
since full GCP access is rarely what you want from a Workspace CLI.
Scopes for work (↑↓ · space · enter · q)
▸ [x] Google Drive
[x] Gmail
[ ] Cloud Platform (full GCP access — leave off unless you need it)
── Gmail (extended) ──
[ ] NEW Read-only
Skip it when you already know what you want, or when there's no terminal to
draw on (CI, ssh without -t, an agent subprocess — where gwx detects the
missing TTY and lets gws choose rather than failing):
gwx login work --scopes https://www.googleapis.com/auth/gmail.readonly
gwx login work --no-pickergwx whoamiAccounts:
work ✓ alice@workdomain.com (14 scopes)
personal ✓ alice@personal.com (14 scopes)
side ✓ alice@side.com (14 scopes)
gws itself is brilliant —
agent-first, JSON in/out, dynamically generated from Google's API
Discovery Service. But it's single-account by design (multi-account
support was removed in v0.7.0).
gwx adds the multi-account contract on top:
- Explicit per-call account selection (no defaults, no inference).
- Parallel reads with a write-refusal allowlist — agents can't
accidentally fan out a
sendto every inbox. - A separate
gwx skills installcommand that fetches the ~95 upstream gws skills, rewrites them for the multi-account contract, and installs them per-project — so heavyweight workflow skills land only in the agents that need them, not every Claude Code session.
⚠️ gwxgives an agent live read+write access to every account you've added — Gmail, Drive, Calendar, Sheets, Docs, People. Sends, shares, deletes, and calendar invites are real-world side effects that can't be undone. Treat each account ingwx whoamilike a logged-in browser tab and review what the agent is about to do, especially for writes.
Once installed, gwx is on your PATH — any Claude Code agent can call
it from Bash without further setup (gwx work gmail users messages list ...).
The remaining setup below makes agents fluent in the wrapper and
prevents them from sidestepping it.
Having gwx on PATH isn't quite enough. Agents don't reach for commands
they don't already know about, and even when they do they tend to use them
naively. The repo ships a small gwx:cli skill that teaches the
wrapper's contract — "always specify an account; only allowlisted verbs
fan out" — so agents discover the binary, understand the rules, and use
it safely. With the skill loaded, Claude also picks gwx up automatically
when you mention your emails, calendar, drive, or other Workspace data —
no need to spell out the command.
There are three ways to load it, depending on the granularity you want:
1. In-session (gwx learn). Best for ad-hoc empowerment of a session
that's already running. Tell the agent:
> run `gwx learn`, then check my latest emails
gwx learn writes the full gwx:cli skill plus an index of all ~95
rewritten gws workflow skills to stdout. The agent reads it into its
context window, learns the contract, and gets a discovery map for every
workflow helper. gwx learn <skill-name> pulls one specific skill inline
without writing anything to disk.
2. Per session (--plugin-dir). Pre-load the skill at session start
so the agent has it from the first turn:
claude --plugin-dir ~/.local/share/gwx # curl-installer layoutThat path is where install.sh puts things. An npm install lives elsewhere —
--plugin-dir "$(dirname "$(dirname "$(readlink -f "$(command -v gwx)")")")"
finds it, or just use option 3 below, which doesn't care how you installed.
3. Globally (plugin marketplace). Install once and every Claude Code CLI session loads the skill automatically (the gwx repo is its own marketplace):
/plugin marketplace add pA1nD/gwx
/plugin install gwx@gwx-marketplace
After that, the gwx:cli skill is available everywhere — no
--plugin-dir flag needed.
gwx's account isolation only holds if the agent goes through the
wrapper. If an agent shells out to gws gmail users messages send ...
directly, account selection drops out and you're back to whatever
account gws defaulted to.
The skill files installed by gwx skills install carry the rule
inline: the gwx-cli skill has a "Never call gws directly" section,
and every rewritten gws-* skill is prefixed with a banner that
repeats it. That's the enforcement — instruction-level, not
policy-level.
A Bash(gws *) deny rule in ~/.claude/settings.json looks like
extra defense, but in practice Claude Code's permission classifier
treats gws as a substring of gwx and blocks legitimate wrapper
calls. The skill-based rule is more reliable. If you still want the
deny as belt-and-braces, add it manually — the installer no longer
prompts for it.
Upstream gws ships a deep library of ~95 skills that turn an agent into a
capable Workspace operator: per-API skills (gws-gmail, gws-drive,
gws-calendar, gws-sheets, gws-docs, gws-people, …), end-to-end
recipes for concrete workflows (+triage an inbox, +meeting-prep
from a calendar event, draft-and-send threads, sheet roll-ups, drive
audits), and personas that give an agent a working role
(executive assistant, researcher, ops). Together they're the difference
between an agent that knows the API exists and one that can actually run
your Monday morning.
For agents that need that context, install the rewritten skills into the agent's directory:
cd ~/path/to/your/agent
gwx skills install # rewrites + installs gws-* into ./.claude/skills/Subset and variants:
gwx skills install --skill gws-gmail gws-drive # subset
gwx skills install --recipes # also recipe-* (concrete workflows)
gwx skills install --personas # also persona-* (role definitions)
gwx skills install --all # everything (~95 skills)
gwx skills uninstall # remove all + manifest
gwx skills uninstall --skill <name>...Upstream skills are written for single-account gws — every example reads
gws gmail users messages list .... Therefore we rewrite each skill during installation:
gws X → gwx <account> X , so the agents can follow these skills correctly.
Trust note:
gwx skills installdoes a shallow clone ofgoogleworkspace/cliand rewrites the markdown into your project. The rewriter doesn't sandbox the content beyond thegws→gwx <account>substitution — anything the upstream skill says to your agent gets passed through. Installing skills means trustinggoogleworkspace/cli'smainHEAD at clone time.
gwx init <name> add an account (prompts for OAuth, offers reuse)
gwx login <name> OAuth flow (shows the scope picker)
--scopes <csv> skip the picker; request exactly these scopes
--no-picker skip the picker; let gws choose scopes
gwx logout <name> clear credentials (keep alias)
gwx remove <name> delete account entirely (alias + creds + client)
gwx whoami list accounts + auth status
gwx --version print the version
gwx <account> <gws args...> run against one account
gwx <a,b> <gws args...> fan out across listed accounts (read-only)
gwx all <gws args...> fan out across every account (read-only)
gwx <account> --help pass-through to gws (full CLI surface)
gwx learn print cli skill + index of all ~95 skills
gwx learn <skill-name> print one cached skill in full
gwx learn --refresh rebuild the cache, then print
gwx skills install [--skill <names>...] [--recipes] [--personas] [--all]
gwx skills uninstall [--skill <names>...]
gwx skills refresh rebuild the rewritten cache from upstream
The rewritten cache lives at ~/.cache/gwx/skills-rewritten/ and is
pre-populated by install.sh so gwx learn and gwx skills install are
both fast and offline. Re-run gwx skills refresh to pick up upstream
changes between gwx releases.
Multi-account fan-out is gated by an allowlist:
list, get, search, schema, +triage, +agenda, +read
Anything else — writes, unknown verbs — must target a single account or
gwx exits with code 3 and points you at the single-account form. This
prevents "send this email to all 3 accounts" type accidents.
Every account always gets a line in the NDJSON, including ones that failed —
a logged-out or expired account comes back as {"account":"side","exit":1, "stdout":"","stderr":"..."} rather than being omitted. Check .exit before
trusting .stdout; an absent account means it wasn't targeted, not that it
had nothing to report.
Fan-out calls are capped by GWX_TIMEOUT (default 30 seconds, per
account). Raise it for wide queries:
GWX_TIMEOUT=120 gwx all gmail users threads list --params '{"maxResults":200}'A timed-out account reports "exit":124. Single-account calls are not capped
— they run as long as gws runs, so an interactive command is never killed
mid-flight.
gwx --help does not duplicate gws's help. However, pass --help through with any
account and you get the full, auto-updating gws CLI surface:
gwx work --help # all services
gwx work gmail --help # one service
gwx work gmail +send --help # a helperThis is the canonical way to learn what's available — there's nothing to hardcode or memorize.
In local mode (the default), every gwx invocation sets
GOOGLE_WORKSPACE_CLI_CONFIG_DIR to a per-account directory under
~/.config/gwx/, so each account writes to its own scope. It also clears
any inherited GOOGLE_WORKSPACE_CLI_* credentials first — …_TOKEN in
particular outranks the config directory inside gws, so a stray one in your
environment would otherwise act as a different account than the one you named.
(In remote mode no config directory is set at all — see
Remote mode.)
~/.config/gwx/ (mode 0700)
├── accounts.list one alias per line
├── gws_bin (0600) pinned absolute path to gws
└── accounts/
├── work/ (0700)
│ ├── client.env (0600) OAuth client_id + secret (plaintext)
│ ├── credentials.enc (0600) refresh token, AES-256-GCM
│ └── token_cache.json (0600) access token, encrypted
├── personal/ (0700)
└── side/ (0700)
Refresh and access tokens are AES-256-GCM encrypted at rest. The
encryption key lives in your OS secret store — macOS Keychain or Windows
Credential Manager — under one shared entry, gws-cli/<your-os-user>.
All gwx accounts share that single key; per-account separation comes
from the filesystem (0700 dirs, 0600 files), not the cipher. On Linux
the keyring path isn't compiled into gws, so the key falls back to a
.encryption_key file alongside each account's ciphertext.
The OAuth client_id / client_secret in client.env are stored as
plaintext shell-quoted env at mode 0600 — same shape as
~/.aws/credentials, ~/.npmrc, or ~/.kube/config.
Run
gwx logincalls one at a time — parallel first-time logins race on the keychain write.
By default gwx holds the credentials: it reads client.env and the encrypted
tokens under ~/.config/gwx/accounts/<name>/ and hands them to gws. Some
deployments invert that — an agent may use an account without ever being able
to read its credential. GWX_MODE=remote is for those:
export GWX_MODE=remote
export GWX_GWS_BIN=/path/to/backend # a drop-in that behaves like gws
gwx alice@example.com gmail +triageIn remote mode gwx never opens a credential file, never sources an OAuth
client secret, and never sets GOOGLE_WORKSPACE_CLI_CONFIG_DIR — naming the
credential directory is most of the way to handing over the credential. The
selected account crosses to the backend in one variable:
GWX_ACCOUNT=alice@example.com
and nothing else does. Whatever owns the credential gives the backend its own access; that is not gwx's business.
Because gwx no longer owns sign-in, init, login, logout and remove are
refused (exit 2) with a message pointing elsewhere, and every other message
stops suggesting them. The account list is still a plain text file at
~/.config/gwx/accounts.list, one name per line — whoever owns the credentials
writes it; gwx only reads it. Everything else is identical: fan-out, the
read-verb classifier, the per-account envelope, whoami, exit codes.
The mode is explicit and never inferred — not from whether GWX_GWS_BIN is
set, not from whether an account directory exists. An unrecognised value is an
error rather than a fallback, because guessing "local" would source exactly the
credentials remote mode exists to keep out of the process.
A backend must be a drop-in for gws: argv verbatim, stdout and stderr
returned, exit code preserved, and auth status answering in gws's own schema
(auth_method, user, scope_count, token_valid — whoami parses all
four). To verify one:
./tests/conformance.sh /path/to/backend alice@example.com0 success
2 bad usage (missing/unknown account, bad flags, verb refused in remote mode)
3 fan-out refused (write/unknown verb)
4 account not authenticated, missing OAuth client, gws/git not installed
* passed through from gws
.
├── .claude-plugin/
│ ├── plugin.json plugin manifest (name="gwx" → namespace gwx:*)
│ └── marketplace.json marketplace manifest (repo IS its own marketplace)
├── bin/gwx the wrapper + inline rewriter (single bash file)
├── bin/gwx-picker multi-select scope picker (pure bash + ANSI)
├── bin/gwx-scopes-build baked catalog + live discovery → picker JSON
├── scopes.json baked OAuth scope catalog
├── package.json npm package (@pa1nd/gwx)
├── skills/cli/SKILL.md the gwx:cli skill
├── install.sh unified install/update/uninstall
├── tests/test.sh bash test suite (no external deps)
├── tests/conformance.sh check a backend stand-in against the contract
├── README.md
├── LICENSE
└── .gitignore
./tests/test.sh # full suite, runs in seconds
./install.sh install --from-folder . # install from this clone
./install.sh uninstall # preserves ~/.config/gwx
./install.sh uninstall --purge # also wipes accounts + cachev0.3.8 — picker layout. The top block is now exactly the eight granted scopes,
so it reads as one statement: this is the grant, everything below is opt-in.
Pub/Sub and Cloud Platform moved out of it into a Google Cloud group — once
switched off they were unchecked rows in the block that otherwise means "you are
about to grant this". The NEW badge and its New · … group prefix are gone;
scopes found via live discovery now merge into the ordinary groups instead of
forming a parallel set, and Gmail (extended) and friends collapse into plain
Gmail. Two tests pin the layout: the top block must equal the default set in
both directions, and nothing below it may be missing a group header.
v0.3.7 — the default scope set is now asserted, and --help names the version.
cloud-platform (full GCP access) was pre-checked in the picker from 27 May to
4 Aug because nothing tested the default set; pubsub rode along granting
access to a service gws does not even expose. Both are off. contacts is on,
so People API lookups work out of the box. Two tests now pin the set: one on
its exact contents, one requiring every pre-checked scope to render in the
picker's visible top block — a checked box scrolled off-screen is how
cloud-platform survived ten weeks. --version and --help read one
constant, so an install lagging behind announces itself. Tests 151 → 153.
v0.3.6 — tests/conformance.sh was measuring its own argv. Its phase-3 probe
sent gmail users labels list with no --params; that is a generated Discovery
command, so gws rejects it locally with a 400 validationError before building
a request — and the script reported it as a backend fault. Reported by a backend
author who lost a debugging cycle to it. Fixed, and a validationError is now
named as this script's bug rather than the backend's. Tests 150 → 151: the new
one runs every probe against the real gws, so it covers any probe added later.
v0.3.5 — gwx login could run the login you just cancelled. The scope picker
ran as scopes-build | picker under set -o pipefail, so a picker that exited
without draining stdin left the writer to die of SIGPIPE; when the picker's own
exit was 0 — confirmed, nothing selected — that 141 was the only non-zero left,
gwx reported "picker failed", and fell through to the gws default picker
instead of aborting. It is staged through a file now, so the picker's exit code
is always its own. Whether it bit you was a race against the pipe buffer, which
is why it only ever surfaced on a loaded CI machine. Tests 148 → 150.
v0.3.4 — tests/conformance.sh gave the wrong verdict in both directions. It
demanded four auth status fields that real gws omits whenever the credential
arrives in the environment, so a faithful backend failed while one that
volunteered an identity on a failed mint passed. It now checks that the
document tells exactly one credential story, rejects reads that come back as
{"error":{"code":401}}, and skips (rather than fails) phase 3 when run
standalone with no gwx to drive. Nothing in bin/ changed; the npm package
is unaffected. Tests 143 → 148.
v0.3.3 — whoami no longer reports ✓-worthy accounts as ✗ when the credential
arrives in the environment rather than a local store. gws reports
auth_method: "none" for an empty credential store, which is exactly what
token-in-environment deployments arrange on purpose; gwx now reads the
token_env_var field alongside it.
v0.3.2 — clears the remaining findings from the same review. gwx all now
emits the per-account envelope even when only one account is configured (the
output shape no longer depends on how many you have), JSON false/null
payloads stay parsed, skills uninstall no longer strands gwx-cli, and
install.sh update can no longer copy the live install onto itself.
Tests 122 → 137.
v0.3.1 — security and correctness release from a multi-model review (codex,
grok, and a fleet of Claude agents with adversarial verification). Closes two
ways a write could reach the wrong account, an OAuth-secret exposure during
login, a case where a fan-out that failed entirely still exited 0, and a way
init could delete existing credentials. Tests 104 → 122. Recommended for
everyone.
v0.3.0 — adds GWX_MODE=remote (see Remote mode) for setups
where something other than gwx holds the credentials. Local behaviour is
unchanged: the mode is opt-in, explicit, and never inferred.
v0.2.0 — fixes four failure-reporting and backend-resolution bugs; most
consequential is that a fan-out account whose call produced no stdout (logged
out, expired, timed out, crashed) was silently dropped from the NDJSON instead
of reporting a row. Anything that consumes fan-out output to decide whether an
account is healthy wants this version or later. Pre-1.0; gws itself is also
pre-1.0, so expect occasional churn. PRs welcome.
MIT © 2026 Björn Schmidtke. See LICENSE.