| name | google-workspace |
| description | Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries otherwise. |
| version | 1.0.0 |
| author | Nous Research |
| license | MIT |
| metadata | {"hermes":{"tags":["Google","Gmail","Calendar","Drive","Sheets","Docs","Contacts","Email","OAuth"],"homepage":"https://github.com/NousResearch/hermes-agent","related_skills":["himalaya"]}} |
Google Workspace
Gmail, Calendar, Drive, Contacts, Sheets, and Docs — through Hermes-managed OAuth and a thin CLI wrapper. When gws is installed, the skill uses it as the execution backend for broader Google Workspace coverage; otherwise it falls back to the bundled Python client implementation.
References
references/gmail-search-syntax.md — Gmail search operators (is:unread, from:, newer_than:, etc.)
Scripts
scripts/setup.py — OAuth2 setup (run once to authorize)
scripts/google_api.py — compatibility wrapper CLI. It prefers gws for operations when available, while preserving Hermes' existing JSON output contract.
First-Time Setup
The setup is fully non-interactive — you drive it step by step so it works
on CLI, Telegram, Discord, or any platform.
Define a shorthand first:
GSETUP="python ${HERMES_HOME:-$HOME/.hermes}/skills/productivity/google-workspace/scripts/setup.py"
Step 0: Check if already set up
$GSETUP --check
If it prints AUTHENTICATED, skip to Usage — setup is already done.
Step 1: Triage — ask the user what they need
Before starting OAuth setup, ask the user TWO questions:
Question 1: "What Google services do you need? Just email, or also
Calendar/Drive/Sheets/Docs?"
-
Email only → They don't need this skill at all. Use the himalaya skill
instead — it works with a Gmail App Password (Settings → Security → App
Passwords) and takes 2 minutes to set up. No Google Cloud project needed.
Load the himalaya skill and follow its setup instructions.
-
Email + Calendar → Continue with this skill, but use
--services email,calendar during auth so the consent screen only asks for
the scopes they actually need.
-
Calendar/Drive/Sheets/Docs only → Continue with this skill and use a
narrower --services set like calendar,drive,sheets,docs.
-
Full Workspace access → Continue with this skill and use the default
all service set.
Question 2: "Does your Google account use Advanced Protection (hardware
security keys required to sign in)? If you're not sure, you probably don't
— it's something you would have explicitly enrolled in."
- No / Not sure → Normal setup. Continue below.
- Yes → Their Workspace admin must add the OAuth client ID to the org's
allowed apps list before Step 4 will work. Let them know upfront.
Step 2: Create OAuth credentials (one-time, ~5 minutes)
Tell the user:
You need a Google Cloud OAuth client. This is a one-time setup:
- Create or select a project:
https://console.cloud.google.com/projectselector2/home/dashboard
- Enable the required APIs from the API Library:
https://console.cloud.google.com/apis/library
Enable: Gmail API, Google Calendar API, Google Drive API,
Google Sheets API, Google Docs API, People API
- Create the OAuth client here:
https://console.cloud.google.com/apis/credentials
Credentials → Create Credentials → OAuth 2.0 Client ID
- Application type: "Desktop app" → Create
- If the app is still in Testing, add the user's Google account as a test user here:
https://console.cloud.google.com/auth/audience
Audience → Test users → Add users
- Download the JSON file and tell me the file path
Important Hermes CLI note: if the file path starts with /, do NOT send only the bare path as its own message in the CLI, because it can be mistaken for a slash command. Send it in a sentence instead, like:
The JSON file path is: /home/user/Downloads/client_secret_....json
Once they provide the path:
$GSETUP --client-secret /path/to/client_secret.json
If they paste the raw client ID / client secret values instead of a file path,
write a valid Desktop OAuth JSON file for them yourself, save it somewhere
explicit (for example ~/Downloads/hermes-google-client-secret.json), then run
--client-secret against that file.
Step 3: Get authorization URL
Use the service set chosen in Step 1. Examples:
$GSETUP --auth-url --services email,calendar --format json
$GSETUP --auth-url --services calendar,drive,sheets,docs --format json
$GSETUP --auth-url --services all --format json
This returns JSON with an auth_url field and also saves the exact URL to
~/.hermes/google_oauth_last_url.txt.
Agent rules for this step:
- Extract the
auth_url field and send that exact URL to the user as a single line.
- Tell the user that the browser will likely fail on
http://localhost:1 after approval, and that this is expected.
- Tell them to copy the ENTIRE redirected URL from the browser address bar.
- If the user gets
Error 403: access_denied, send them directly to https://console.cloud.google.com/auth/audience to add themselves as a test user.
Step 4: Exchange the code
The user will paste back either a URL like http://localhost:1/?code=4/0A...&scope=...
or just the code string. Either works. The --auth-url step stores a temporary
pending OAuth session locally so --auth-code can complete the PKCE exchange
later, even on headless systems:
$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED" --format json
If --auth-code fails because the code expired, was already used, or came from
an older browser tab, it now returns a fresh fresh_auth_url. In that case,
immediately send the new URL to the user and have them retry with the newest
browser redirect only.
Step 5: Verify
$GSETUP --check
Should print AUTHENTICATED. Setup is complete — token refreshes automatically from now on.
Notes
- Token is stored at
~/.hermes/google_token.json and auto-refreshes.
- Pending OAuth session state/verifier are stored temporarily at
~/.hermes/google_oauth_pending.json until exchange completes.
- If
gws is installed, google_api.py points it at the same ~/.hermes/google_token.json credentials file. Users do not need to run a separate gws auth login flow.
- To revoke:
$GSETUP --revoke
Usage
All commands go through the API script. Set GAPI as a shorthand:
GAPI="python ${HERMES_HOME:-$HOME/.hermes}/skills/productivity/google-workspace/scripts/google_api.py"
Gmail
$GAPI gmail search "is:unread" --max 10
$GAPI gmail search "from:boss@company.com newer_than:1d"
$GAPI gmail search "has:attachment filename:pdf newer_than:7d"
$GAPI gmail get MESSAGE_ID
$GAPI gmail send --to user@example.com --subject "Hello" --body "Message text"
$GAPI gmail send --to user@example.com --subject "Report" --body "<h1>Q4</h1><p>Details...</p>" --html
$GAPI gmail send --to user@example.com --subject "Hello" --from '"Research Agent" <user@example.com>' --body "Message text"
$GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me."
$GAPI gmail reply MESSAGE_ID --from '"Support Bot" <user@example.com>' --body "Thanks"
$GAPI gmail labels
$GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID
$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD
Calendar
$GAPI calendar list
$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z
$GAPI calendar create --summary "Team Standup" --start 2026-03-01T10:00:00-06:00 --end 2026-03-01T10:30:00-06:00
$GAPI calendar create --summary "Lunch" --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z --location "Cafe"
$GAPI calendar create --summary "Review" --start 2026-03-01T14:00:00Z --end 2026-03-01T15:00:00Z --attendees "alice@co.com,bob@co.com"
$GAPI calendar delete EVENT_ID
Drive
$GAPI drive search "quarterly report" --max 10
$GAPI drive search "mimeType='application/pdf'" --raw-query --max 5
Contacts
$GAPI contacts list --max 20
Sheets
$GAPI sheets get SHEET_ID "Sheet1!A1:D10"
$GAPI sheets update SHEET_ID "Sheet1!A1:B2" --values '[["Name","Score"],["Alice","95"]]'
$GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]'
Docs
$GAPI docs get DOC_ID
Output Format
All commands return JSON. Parse with jq or read directly. Key fields:
- Gmail search:
[{id, threadId, from, to, subject, date, snippet, labels}]
- Gmail get:
{id, threadId, from, to, subject, date, labels, body}
- Gmail send/reply:
{status: "sent", id, threadId}
- Calendar list:
[{id, summary, start, end, location, description, htmlLink}]
- Calendar create:
{status: "created", id, summary, htmlLink}
- Drive search:
[{id, name, mimeType, modifiedTime, webViewLink}]
- Contacts list:
[{name, emails: [...], phones: [...]}]
- Sheets get:
[[cell, cell, ...], ...]
Rules
- Never send email or create/delete events without confirming with the user first. Show the draft content and ask for approval.
- Check auth before first use — run
setup.py --check. If it fails, guide the user through setup.
- Use the Gmail search syntax reference for complex queries — load it with
skill_view("google-workspace", file_path="references/gmail-search-syntax.md").
- Calendar times must include timezone — always use ISO 8601 with offset (e.g.,
2026-03-01T10:00:00-06:00) or UTC (Z).
- Respect rate limits — avoid rapid-fire sequential API calls. Batch reads when possible.
Gmail — Scopes Corretos (CRÍTICO!)
Para Gmail COMPLETO: usar https://mail.google.com/
Este scope dá acesso total: ler, enviar, excluir, arquivar, criar labels/pastas.
Scopes INVÁLIDOS (NÃO USAR - causam invalid_scope):
- ❌
gmail.delete, gmail.full, gmail.labels, gmail.metadata, gmail.other
- ❌ Scopes individuais inválidos causam
Error 400: invalid_scope
Resumo — Scopes que FUNCIONAM
| Serviço | Scope |
|---|
| Gmail COMPLETO | https://mail.google.com/ |
| Google Docs | https://www.googleapis.com/auth/documents |
| Google Sheets | https://www.googleapis.com/auth/spreadsheets |
| Google Drive | https://www.googleapis.com/auth/drive |
| Google Calendar | https://www.googleapis.com/auth/calendar |
| Contacts | https://www.googleapis.com/auth/contacts.readonly |
Troubleshooting
| Problem | Fix |
|---|
NOT_AUTHENTICATED | Run setup Steps 2-5 above |
REFRESH_FAILED | Token revoked or expired — redo Steps 3-5 |
HttpError 403: Insufficient Permission | Missing API scope — $GSETUP --revoke then redo Steps 3-5 |
HttpError 403: Access Not Configured | API not enabled — user needs to enable it in Google Cloud Console |
ModuleNotFoundError | Run $GSETUP --install-deps |
Error 400: invalid_scope | Gmail scope inválido — usar https://mail.google.com/ |
| Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID |
◆ Google OAuth Troubleshooting — Labeled Subsection
Source: google/google-oauth-troubleshoot/
Problem: Browser Opens on Server, Not Álvaro's PC
Browser OAuth opens on the SERVER (localhost:63189), not on Álvaro's PC. Álvaro can't see the consent page.
Solution: Manual URL Flow
- Generate auth URL and send to Álvaro (NEVER auto-open browser)
- Ask Álvaro to paste URL into HIS browser
- Ask Álvaro to paste back the redirected URL with
code=
Key Code for Token Exchange
import urllib.parse, urllib.request, json
client_id = "443336072194-emc1p7m6ovth7tc070dh0sqmep00rdna.apps.googleusercontent.com"
redirect_uri = "http://localhost:1"
scopes = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
]
url = (
"https://accounts.google.com/o/oauth2/auth"
f"?client_id={client_id}"
f"&redirect_uri={urllib.parse.quote(redirect_uri, safe='')}"
f"&scope={' '.join(scopes)}"
"&response_type=code"
"&access_type=offline"
"&prompt=consent"
)
print(url)
Credentials Location
~/.hermes/google_token.json — access + refresh tokens
~/.hermes/google_client_secret.json — client_id + client_secret
Root Cause: Current Scopes Are Too Narrow
Current: drive.file + documents — only sees files created BY the app.
Needed: drive (or drive.file for limited access).
◆ Google Docs API — Labeled Subsection
Source: productivity/google-oauth-docs-api/
Key Discoveries (trial and error)
- Client secret MUST be Desktop app type — NOT Web application
- JSON field must be
installed with redirect_uris http localhost
- Code expires quickly — generate URL and exchange for token IMMEDIATELY
- Scopes must be requested together in first authorization
- Create document:
body = {"title": "..."} — DO NOT use {"properties": {"title": ...}} (error 400: "Unknown name 'properties'")
- insertText: up to 1MB per request — 60k chars inserted at once worked
- Rate limit 429: insert in ~400-char chunks with 1.5s delay between each
Flow
- Create OAuth client (Desktop app type) in Google Cloud Console
- Add account as test user: OAuth consent screen
- Generate auth URL with PKCE (code_verifier + code_challenge)
- Open URL in browser logged into correct account
- Copy code from redirect URL (localhost/?code=...)
- Exchange code for token immediately
Google Docs API
- Create document via Drive API (
mimeType: application/vnd.google-apps.document)
- Insert text via
batchUpdate with insertText
- Scope:
documents for write, drive.file for file creation
Files
~/.hermes/google_client_secret_final.json
~/.hermes/google_token.json
~/.hermes/google_code_verifier.txt
Google Drive API — Critical Quirks (learned the hard way)
Token field name: token, NOT access_token
The OAuth token JSON uses token as the key, not access_token (unlike the standard OAuth2 spec).
token = json.load(open('google_token.json'))['access_token']
t = json.load(open('google_token.json'))
token = t.get('token', t.get('access_token', ''))
Drive API: PATCH move returns 200 but does NOT persist
Files: update with addParents/removeParents returns HTTP 200 but the parent change is silently ignored.
This is a known Google Drive API behavior with certain OAuth flows. The API lies — 200 does not mean success.
Workaround — download then re-upload:
- Download file:
GET /drive/v3/files/{id}?alt=media
- Create new file in destination folder:
POST /drive/v3/files with parents: [destination_id]
- Upload content:
PATCH /drive/v3/files/{new_id}?uploadType=multipart with multipart body
- Delete original:
DELETE /drive/v3/files/{original_id}
Multipart upload body format (Python):
boundary = 'boundary_' + os.urandom(16).hex()
metadata = json.dumps({'name': filename, 'parents': [dest_id], 'mimeType': mime_type})
body = (
f'--{boundary}\r\n'
f'Content-Type: application/json; charset=UTF-8\r\n\r\n'
f'{metadata}\r\n'
f'--{boundary}\r\n'
f'Content-Type: {mime_type}\r\n\r\n'
).encode('utf-8') + file_content + f'\r\n--{boundary}--\r\n'.encode('utf-8')
r = requests.post(
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
headers={'Authorization': f'Bearer {token}', 'Content-Type': f'multipart/related; boundary={boundary}'},
data=body, timeout=300
)
Root parent ID: The root folder's ID is 0AA8BofThaKdlUk9PVA (not the string "root").
Drive folder structure (Álvaro's Drive — 30/04/2026)
| Folder | ID | Contents |
|---|
| Processados | 1Qaqe5DL9rE2tbL_KrvlAdYfwvMSjwPUA | Processed AI books |
| RAG - Livros a Provessar | 1Dvk2Ty-xsRerRf4ZpZpeQqP6TTlt8JRe | Books to vectorize |
Caution: A subfolder named "Processados" can exist inside "RAG - Livros a Provessar" — this causes confusion.
Always list folder contents by querying '{folder_id}' in parents explicitly, not relying on names.
Revoking Access
$GSETUP --revoke