Skip to content

gws — Integration Guide

Generated: 2026-05-14 CLI version: 0.17.0 Scope: Critical (22 failure modes)

Invocation Invariants

These constraints must hold on every call to gws, regardless of language or framework:

binary:  gws
         (/opt/homebrew/bin/gws)
stdin:   closed (subprocess.DEVNULL or equivalent)
timeout: 30s external — gws has no --timeout flag
         macOS: perl -e 'alarm(30); exec @ARGV' -- gws <args>
         Python: subprocess.run([...], timeout=30)
env:     GOOGLE_WORKSPACE_CLI_TOKEN=<token>    # §45,§53,§64 — bypass stored creds; only safe headless auth method
output:  always parse stdout as JSON regardless of exit code
         exit 0 does NOT guarantee success — auth errors on list commands return exit 0

Per-Failure-Mode Workarounds (score < 3, sorted: severity desc, score asc)

§11 — Timeouts & Hanging Processes [Critical · 0/3]

Gap: No --timeout flag; network hangs block indefinitely.

Workaround: Wrap every gws call with an external timeout. On macOS (no GNU timeout):

import subprocess, json

def gws(args: list[str], timeout: int = 30) -> dict:
    cmd = ["perl", "-e", f"alarm({timeout}); exec @ARGV", "--"] + ["gws"] + args
    result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL)
    if result.returncode == 14:  # SIGALRM
        raise TimeoutError(f"gws {args[0]} timed out after {timeout}s")
    return json.loads(result.stdout)

Limitation: External timeout cannot distinguish network hang from a legitimately slow large response. Set timeout generously (60s) for large Drive file operations.


§13 — Partial Failure & Atomicity [Critical · 0/3]

Gap: Workflow commands (gws workflow +standup-report, etc.) have no partial failure structure. On failure, the agent does not know what completed.

Workaround: Break multi-step workflows into individual gws calls with explicit state tracking:

completed = []
steps = [
    (["drive", "files", "list", "--params", '{"pageSize":5}'], "list_files"),
    (["gmail", "users", "messages", "list", "--params", '{"userId":"me","maxResults":5}'], "list_emails"),
]
for args, step_name in steps:
    result = gws(args)
    if not result.get("ok", True) and "error" not in result:
        # gws does not return ok field — check for error key
        raise RuntimeError(f"Step {step_name} failed: {result}")
    completed.append(step_name)

Do not use gws workflow commands in production agent code — their partial failure behavior is opaque.

Limitation: Individual API calls are still not atomic. If a multi-step workflow requires rollback, implement compensating actions manually.


§25 — Prompt Injection via Output [Critical · 0/3]

Gap: gws returns external data (email bodies, document content, file names) as raw strings. LLMs consuming this output may follow injected instructions.

Workaround: Never pass raw gws output containing user-generated content directly to the LLM. Extract the specific fields your agent needs and pass only those:

result = gws(["gmail", "users", "messages", "get",
              "--params", json.dumps({"userId": "me", "id": msg_id})])
# BAD: pass entire result to LLM
# GOOD: extract only what you need
email = {
    "from": result.get("data", result).get("payload", {}).get("headers", []),
    "subject": ...,   # extract from headers
    # do NOT include "body" unless required
}

Tag external data before including in LLM context:

EXTERNAL_MARKER = "\n--- EXTERNAL DATA (untrusted) ---\n"
llm_context = f"Email subject: {EXTERNAL_MARKER}{subject}\n--- END EXTERNAL DATA ---"

Limitation: Manual extraction is fragile and easy to miss. Any field that contains user-generated content (file names, email subjects, document titles) is a potential injection vector.


§53 — Credential Expiry Mid-Session [Critical · 0/3]

Gap: Expired credentials return reason: "authError" — identical to permanent permission denial. No CREDENTIALS_EXPIRED code, no reauth_command, no expired_at.

Workaround: Detect expiry by inspecting the raw error message for OAuth expiry signals, and treat all auth errors as potentially retriable once:

import subprocess, json, os, re

EXPIRY_SIGNALS = re.compile(r"invalid_rapt|invalid_grant|token.*expired|reauth", re.IGNORECASE)
PERM_SIGNALS   = re.compile(r"insufficient_scope|access_denied|forbidden", re.IGNORECASE)

def gws_with_auth_retry(args: list[str], max_retries: int = 1) -> dict:
    for attempt in range(max_retries + 1):
        result = subprocess.run(
            ["gws"] + args,
            capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30
        )
        data = json.loads(result.stdout)
        error = data.get("error", {})
        msg = str(error.get("message", ""))

        if not error:
            return data  # success

        if PERM_SIGNALS.search(msg):
            raise PermissionError(f"Permanent auth failure: {error}")

        if EXPIRY_SIGNALS.search(msg) and attempt < max_retries:
            # Attempt refresh — requires user to re-run gws auth login
            raise RuntimeError(
                f"Credentials expired (invalid_rapt). Run `gws auth login` to refresh, "
                f"then set GOOGLE_WORKSPACE_CLI_TOKEN. Error: {msg}"
            )

        raise RuntimeError(f"gws call failed: {error}")
    raise RuntimeError("Auth retry limit reached")

Limitation: Cannot auto-refresh credentials because gws auth login requires a browser. Expiry forces human intervention. Mitigate by using short-lived tokens with early-refresh logic, or set GOOGLE_WORKSPACE_CLI_TOKEN from a token-refresh service.


§1 — Exit Codes & Status Signaling [Critical · 1/3]

Gap: Auth errors exit 0 on list commands, exit 2 on get commands. Never branch on exit code alone.

Workaround: Always parse stdout as JSON and check for an error key, regardless of exit code:

import subprocess, json

result = subprocess.run(
    ["gws"] + args, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30
)
data = json.loads(result.stdout)
if "error" in data:
    code = data["error"].get("code")
    reason = data["error"].get("reason", "")
    raise RuntimeError(f"gws error {code} ({reason}): {data['error']['message']}")
# success — use data directly

Map known numeric HTTP codes to actions:

HTTP_TO_ACTION = {
    400: "fix_params",    # bad request — do not retry
    401: "auth_retry",   # auth failure — check token
    403: "escalate",     # forbidden — do not retry
    404: "not_found",    # resource does not exist
    429: "backoff",      # rate limited — retry after delay
}

Limitation: The error.code field contains HTTP codes (400, 401), not symbolic names. Map defensively — future gws versions may change this.


§2 — Output Format & Parseability [Critical · 1/3]

Gap: No top-level ok/data/meta envelope. Success response structure varies by API method. Prose error line also emitted to stderr.

Workaround: Normalize the response before use. On success, gws returns the raw API response directly (no ok wrapper); on error it returns {"error": {...}}:

def parse_gws_output(stdout: str, stderr: str) -> dict:
    data = json.loads(stdout)
    if "error" in data:
        return {"ok": False, "error": data["error"]}
    # Success: raw API response — wrap it
    return {"ok": True, "data": data}

# stderr contains a duplicate prose error line — ignore it
# stderr may also contain ANSI debug output if GOOGLE_WORKSPACE_CLI_LOG is set

Limitation: Success response structure is the raw Google API JSON, which varies per method. Use gws schema <method> to discover the response schema before parsing.


§12 — Idempotency & Safe Retries [Critical · 1/3]

Gap: No --idempotency-key and no effect field. Retrying mutating calls (send email, create event) may cause duplicates.

Workaround: For mutating operations, run a pre-flight read to confirm the action is needed before writing:

# Before creating a calendar event, check if it already exists
existing = gws(["calendar", "events", "list", "--params",
                json.dumps({"calendarId": "primary", "q": event_title})])
if existing.get("items"):
    return existing["items"][0]  # already exists — skip create

# Only create if not found
return gws(["calendar", "events", "insert", "--json", json.dumps(event_body),
            "--params", '{"calendarId":"primary"}'])

Track operation IDs in your agent's state to detect duplicates:

if operation_id in completed_operations:
    return completed_operations[operation_id]

Limitation: Read-before-write has a TOCTOU race. This is best-effort deduplication, not true idempotency. For critical write operations (financial, email), require human confirmation before first attempt.


§23 — Side Effects & Destructive Operations [Critical · 1/3]

Gap: --dry-run validates locally but does not return a would_delete envelope showing what would be affected. No danger_level in schema.

Workaround: Always run --dry-run before destructive commands and confirm the target before proceeding:

# Step 1: dry-run to see what would happen
dry = gws(["drive", "files", "delete", "--params",
           json.dumps({"fileId": file_id}), "--dry-run"])
# dry-run exits 0 but does not show "would_delete" — it only validates params

# Step 2: fetch the file metadata first to confirm identity
meta = gws(["drive", "files", "get", "--params",
            json.dumps({"fileId": file_id, "fields": "id,name,size"})])
file_name = meta.get("name", "unknown")

# Step 3: require explicit confirmation in agent plan before deleting

Limitation: --dry-run only validates request params locally — it does not contact the API or confirm the resource exists. Always fetch resource metadata before destructive calls.


§34 — Shell Injection via Agent-Constructed Commands [Critical · 1/3]

Gap: No metacharacter validation at CLI layer. LLM-generated resource IDs or query values passed directly to --params reach the API unvalidated.

Workaround: Validate all LLM-generated values before passing to --params or --json:

import re, urllib.parse

SAFE_ID_RE   = re.compile(r'^[\w\-\.]+$')
TRAVERSAL_RE = re.compile(r'\.\./|%[0-9a-fA-F]{2}|[?#;|<>`$]')

def safe_param(value: str, field_name: str) -> str:
    if TRAVERSAL_RE.search(value):
        raise ValueError(f"Unsafe value for {field_name}: {value!r}")
    return value

# Usage
file_id = safe_param(llm_generated_file_id, "fileId")
params = json.dumps({"fileId": file_id})

Never use shell=True when calling gws. Always use exec-array form:

subprocess.run(["gws", "drive", "files", "get", "--params", params], ...)
# NOT: subprocess.run(f"gws drive files get --params '{params}'", shell=True)

Limitation: Validation catches common patterns but cannot anticipate all LLM hallucination patterns. Review --params values from LLM output before any write or delete call.


§42 — Debug / Trace Mode Secret Leakage [Critical · 1/3]

Gap: GOOGLE_WORKSPACE_CLI_LOG=gws=debug emits ANSI codes to stderr. No auto-redaction of token values in debug output.

Workaround: Never set GOOGLE_WORKSPACE_CLI_LOG in production agent code. If debugging is required, strip ANSI codes from captured stderr before logging:

import re
ANSI_RE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]')

def strip_ansi(text: str) -> str:
    return ANSI_RE.sub('', text)

result = subprocess.run(["gws"] + args, capture_output=True, text=True)
clean_stderr = strip_ansi(result.stderr)

Never log the value of GOOGLE_WORKSPACE_CLI_TOKEN or any *_SECRET environment variable. Redact before logging:

def redact_env(env: dict) -> dict:
    return {k: "[REDACTED]" if any(s in k.upper() for s in ["TOKEN","SECRET","KEY","PASSWORD"]) else v
            for k, v in env.items()}

Limitation: Cannot prevent the token from appearing in debug output if GOOGLE_WORKSPACE_CLI_LOG is set externally. Ensure debug logging is disabled in production via environment controls.


§43 — Tool Output Result Size Unboundedness [Critical · 1/3]

Gap: --page-limit 10 caps list pagination but not individual response body size. Large documents or emails return in full.

Workaround: Always request minimal fields from the API using the fields parameter to limit response size:

# Instead of returning the full file metadata
gws(["drive", "files", "list", "--params", '{"pageSize":10}'])

# Request only the fields you need
gws(["drive", "files", "list", "--params",
     json.dumps({"pageSize": 10, "fields": "files(id,name,size,modifiedTime)"})])

For document/email bodies, always paginate or truncate:

# Gmail: only get metadata, not body, unless needed
gws(["gmail", "users", "messages", "get",
     "--params", json.dumps({"userId": "me", "id": msg_id, "format": "metadata"})])

# Get body separately and truncate
body_result = gws(["gmail", "users", "messages", "get",
                   "--params", json.dumps({"userId": "me", "id": msg_id, "format": "full"})])
body = body_result.get("payload", {}).get("body", {}).get("data", "")[:10000]  # 10KB cap

Pre-estimate output size for large operations before running.

Limitation: The fields parameter is a Google API feature, not a gws feature. Some APIs do not support it. Body size limits are manual — gws does not enforce them automatically.


§45 — Headless Authentication / OAuth Browser Flow Blocking [Critical · 1/3]

Gap: Auth errors exit 0 (not 8). No AUTH_REQUIRED code and no auth_methods array.

Workaround: Pre-check authentication before any command sequence using gws auth status:

def verify_auth() -> bool:
    result = subprocess.run(
        ["gws", "auth", "status"],
        capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=10
    )
    try:
        status = json.loads(result.stdout)
        return status.get("token_valid", False)
    except json.JSONDecodeError:
        return False

if not verify_auth():
    raise RuntimeError(
        "gws authentication required. Set GOOGLE_WORKSPACE_CLI_TOKEN or run "
        "`gws auth login` in an interactive session, then export the token."
    )

Set GOOGLE_WORKSPACE_CLI_TOKEN from a token service to bypass stored credentials entirely:

env = {**os.environ, "GOOGLE_WORKSPACE_CLI_TOKEN": token_service.get_token()}
subprocess.run(["gws", ...], env=env, ...)

Limitation: gws auth login requires a browser; there is no headless device-code or service-account flow built into gws. For fully headless operation, obtain the OAuth token externally (e.g., using the Google Python client library) and pass via GOOGLE_WORKSPACE_CLI_TOKEN.


§60 — OS Output Buffer Deadlock [Critical · 1/3]

Gap: Single-shot API calls; no heartbeat for workflow commands; ANSI in debug stderr.

Workaround: For workflow commands that may take multiple seconds, set a generous timeout and run in a thread with progress logging:

import subprocess, threading, time

def run_with_progress(args: list[str], timeout: int = 60) -> dict:
    result_holder = {}
    def _run():
        r = subprocess.run(["gws"] + args, capture_output=True, text=True,
                           stdin=subprocess.DEVNULL, timeout=timeout)
        result_holder["result"] = r

    t = threading.Thread(target=_run, daemon=True)
    t.start()
    elapsed = 0
    while t.is_alive():
        time.sleep(5); elapsed += 5
        print(f"[{elapsed}s] gws {args[0]} still running...", flush=True)
    t.join()
    return json.loads(result_holder["result"].stdout)

Limitation: No built-in heartbeat from gws; the polling above is agent-side only. Long-running gws workflow commands are opaque — no step-level progress.


§64 — Headless Display and GUI Launch Blocking [Critical · 1/3]

Gap: gws auth login opens a browser; no --print-url flag.

Workaround: Never call gws auth login from agent code. Obtain auth tokens externally and inject via env var:

import os
# Obtain token outside agent (e.g., from a token service, Vault, or one-time human setup)
os.environ["GOOGLE_WORKSPACE_CLI_TOKEN"] = get_token_from_service()

# All subsequent gws calls will use this token

For CI environments, use a service account token generated via the Google Cloud SDK:

# One-time human setup:
gcloud auth print-access-token --scopes=https://www.googleapis.com/auth/drive.readonly
# → export as GOOGLE_WORKSPACE_CLI_TOKEN in CI secrets

Limitation: GOOGLE_WORKSPACE_CLI_TOKEN accepts only OAuth2 access tokens (short-lived, ~1hr). Service accounts require generating tokens via the Google auth library, not via gws auth login. There is no service-account JSON key support built into gws.


§71 — Non-Interactive Installation Absence [Critical · 1/3]

Gap: Install via Homebrew is non-interactive but not documented in AGENTS.md. Version 0.17.0 is outdated (0.22.5 available).

Workaround: Use this non-interactive install sequence in CI/agent setup scripts:

# Install (non-interactive, idempotent)
brew install googleworkspace-cli

# Verify
gws --version   # exits 0 if installed correctly

# Auth (must be done once in an interactive session before agent use)
# Then export token for headless agent use:
export GOOGLE_WORKSPACE_CLI_TOKEN=$(gws auth export --json | jq -r '.access_token')

Update to 0.22.5: brew upgrade googleworkspace-cli

Limitation: Homebrew install is macOS-only. Linux/container environments require the binary to be downloaded directly from the GitHub releases page — no package manager support is documented.


§74 — Credential Scope Declaration Absence [Critical · 1/3]

Gap: gws schema returns all possible OAuth scopes, not minimal required scopes. No check-permissions command. Agents may use over-privileged credentials.

Workaround: Manually specify the minimal scopes when creating OAuth credentials via gws auth login:

# Request only the scopes needed for your workflow
gws auth login --scopes https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/gmail.readonly

# Or use service-level restriction
gws auth login -s drive,gmail --readonly

Before any agentic workflow, run gws auth status and verify the active scopes match the minimum required:

status = gws(["auth", "status"])
active_scopes = status.get("scopes", [])
required = {"https://www.googleapis.com/auth/drive.readonly"}
if not required.issubset(set(active_scopes)):
    raise RuntimeError(f"Missing required scopes: {required - set(active_scopes)}")

Limitation: gws has no required_scopes per command. The scope list from gws schema shows all scopes that could work, not the minimal set. Manual scope management is required until gws adds required_scopes to schema output.


§10 — Interactivity & TTY Requirements [Critical · 2/3]

Gap: No --non-interactive flag. gws does not hang on stdin=DEVNULL for API commands, but gws auth login opens browser without TTY check.

Workaround: Always pass stdin=subprocess.DEVNULL. Never call gws auth login from agent code (see §64 workaround).

subprocess.run(["gws"] + args, stdin=subprocess.DEVNULL, capture_output=True, text=True)

Limitation: No hang risk on current API commands, but the --non-interactive flag is absent, so future commands that add prompts would hang without this workaround.


§24 — Authentication & Secret Handling [Critical · 2/3]

Gap: Credentials via env vars only (correct). No --secret-from-file flag for container environments.

Workaround: Load token from a secrets file at runtime and inject via env var (rather than mounting as env var directly):

import os
token = open("/run/secrets/gws-token").read().strip()
env = {**os.environ, "GOOGLE_WORKSPACE_CLI_TOKEN": token}
subprocess.run(["gws", ...], env=env, stdin=subprocess.DEVNULL)

Limitation: Token is briefly in memory but not exposed via process table or CLI flags. Ensure the secrets file has appropriate permissions (chmod 600).


§61 — Bidirectional Pipe Payload Deadlock [Critical · 2/3]

Gap: No stdin data path means no pipe deadlock risk. However, very large --json string arguments (spreadsheet batch updates, etc.) may exceed OS argument limits.

Workaround: For large request bodies, write to a temp file and pass via shell substitution is unsafe. Instead, keep payloads under 64KB in --json or use the Sheets/Docs API directly for bulk operations:

import json, os

body = {"values": large_2d_array}  # may be large
body_str = json.dumps(body)

if len(body_str) > 65536:
    raise ValueError(
        f"Request body {len(body_str)} bytes exceeds safe --json arg size. "
        "Split the operation into smaller batches."
    )

gws(["sheets", "spreadsheets", "values", "append",
     "--params", json.dumps({"spreadsheetId": sid, "range": "Sheet1"}),
     "--json", body_str])

Limitation: No --json-file flag exists — large payloads must be batched or sent via the Google API Python client directly.


No Action Needed

§37 (REPL Triggering), §50 (Stdin Deadlock), §62 (Editor Trap) — score 3/3, no workaround required