openclaw/skills/gws-gmail/SKILL.md
Read, search, send, and manage Gmail messages for Dylan, Julia, or other accounts. Use when asked about email, inbox, unread messages, sending emails, checking mail, or Julia's email.
npx skillsauth add Dbochman/dotfiles gws-gmailInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Access Gmail via the gws CLI at /opt/homebrew/bin/gws. Credentials are AES-256-GCM encrypted at ~/.config/gws/.
Read references/gws-cli.md before selecting an account, changing authentication, or executing a write.
| Account | Owner | Raw Gmail API selector |
|---------|-------|-------------------------|
| ${DYLAN_EMAIL} | Dylan | GOOGLE_WORKSPACE_CLI_ACCOUNT=${DYLAN_EMAIL} |
| ${JULIA_EMAIL} | Julia | GOOGLE_WORKSPACE_CLI_ACCOUNT=${JULIA_EMAIL} |
| ${STARMARKET_GMAIL} | Dylan (spam) | GOOGLE_WORKSPACE_CLI_ACCOUNT=${STARMARKET_GMAIL} |
| ${OPENCLAW_EMAIL} | OpenClaw | GOOGLE_WORKSPACE_CLI_ACCOUNT=${OPENCLAW_EMAIL} |
When Dylan asks about "my email", use default. When he says "Julia's email", use her account.
Raw Gmail API commands follow:
GOOGLE_WORKSPACE_CLI_ACCOUNT=<email> gws gmail <resource> <method> [--params '<JSON>'] [--json '<JSON>']
--params = URL/query parameters (userId, q, maxResults, etc.)--json = request body (for send, modify, etc.)With pinned GWS 0.4.4, do not use --account on raw API resource commands;
that flag is not reliably routed and can return No credentials provided.
Use GOOGLE_WORKSPACE_CLI_ACCOUNT instead. Reserve --account for
authentication subcommands that explicitly list it in gws auth --help.
Important: Most Gmail endpoints require "userId": "me" in params.
# Unread messages
gws gmail users messages list --params '{
"userId": "me",
"q": "is:unread",
"maxResults": 10
}'
# From a specific sender
gws gmail users messages list --params '{
"userId": "me",
"q": "from:[email protected]"
}'
# With attachments, recent
gws gmail users messages list --params '{
"userId": "me",
"q": "has:attachment newer_than:7d"
}'
# Julia's unread
GOOGLE_WORKSPACE_CLI_ACCOUNT=${JULIA_EMAIL} gws gmail users messages list --params '{
"userId": "me",
"q": "is:unread",
"maxResults": 10
}'
| Query | Meaning |
|-------|---------|
| is:unread | Unread messages |
| is:starred | Starred messages |
| is:important | Important messages |
| in:inbox | Messages in inbox |
| in:sent | Sent messages |
| from:[email protected] | From specific sender |
| to:[email protected] | To specific recipient |
| subject:keyword | Subject contains keyword |
| has:attachment | Has attachments |
| filename:pdf | Has PDF attachment |
| after:2026/01/01 | After date |
| before:2026/02/01 | Before date |
| newer_than:7d | Newer than 7 days |
| older_than:1m | Older than 1 month |
| label:work | Has label |
| category:promotions | In category |
Combine queries: from:[email protected] is:unread after:2026/01/01
# Full message with body
gws gmail users messages get --params '{
"userId": "me",
"id": "<messageId>",
"format": "full"
}'
# Metadata only (headers)
gws gmail users messages get --params '{
"userId": "me",
"id": "<messageId>",
"format": "metadata"
}'
With pinned GWS 0.4.4, passing metadataHeaders through --params can return
payload without a headers array. Omit that parameter, request metadata, and
filter the returned headers locally. Do not pipe an API error object into jq;
check the command status and .error first.
The metadata response does NOT put headers at the top level. They're nested under payload.headers as an array of {name, value} objects:
{
"id": "...",
"internalDate": "1777719291000",
"labelIds": ["INBOX", ...],
"payload": {
"headers": [
{"name": "From", "value": "..."},
{"name": "Subject", "value": "..."},
{"name": "Date", "value": "..."}
]
}
}
To extract them flat, pipe through jq:
if ! out=$(gws gmail users messages get --params '{"userId":"me","id":"<id>","format":"metadata"}' 2>&1); then
echo "Gmail metadata unavailable" >&2
exit 1
fi
jq -e '(.error? == null) and (.payload.headers | type == "array")' <<<"$out" >/dev/null || {
echo "Gmail metadata unavailable" >&2
exit 1
}
jq '.payload.headers | map(select(.name == "From" or .name == "Subject" or .name == "Date") | {(.name): .value}) | add' <<<"$out"
# → {"From":"[email protected]","Subject":"...","Date":"..."}
For batch summaries (e.g., morning briefing), loop a list call's IDs through this pattern and concat — don't fall back to format: "full" snippets, which is slow and lossy.
gws gmail users threads get --params '{
"userId": "me",
"id": "<threadId>",
"format": "full"
}'
Messages must be base64url-encoded RFC 2822 format.
IMPORTANT: Use Python base64.urlsafe_b64encode to encode the raw email. Do NOT use shell printf | base64 | tr — it corrupts ! to \! and mangles special characters.
# Step 1: Build base64url payload with Python (safe for all characters)
RAW_B64=$(python3 -c "
import base64
msg = 'From: [email protected]\r\nTo: [email protected]\r\nSubject: Hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nMessage body here!'
print(base64.urlsafe_b64encode(msg.encode()).decode().rstrip('='))
")
# Step 2: Send via gws
gws gmail users messages send --params '{"userId": "me"}' \
--json "{\"raw\":\"${RAW_B64}\"}"
# Reply to a thread (add threadId, In-Reply-To, References headers)
RAW_B64=$(python3 -c "
import base64
msg = 'From: [email protected]\r\nTo: [email protected]\r\nSubject: Re: Original Subject\r\nIn-Reply-To: <original-message-id>\r\nReferences: <original-message-id>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nReply body here'
print(base64.urlsafe_b64encode(msg.encode()).decode().rstrip('='))
")
gws gmail users messages send --params '{"userId": "me"}' \
--json "{\"threadId\":\"<threadId>\",\"raw\":\"${RAW_B64}\"}"
For HTML emails, use Content-Type: text/html; charset=utf-8 in the headers.
# List drafts
gws gmail users drafts list --params '{"userId": "me"}'
# Create a draft (use Python base64 pattern from Send section)
RAW_B64=$(python3 -c "
import base64
msg = 'To: [email protected]\r\nSubject: Draft\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nDraft body'
print(base64.urlsafe_b64encode(msg.encode()).decode().rstrip('='))
")
gws gmail users drafts create --params '{"userId": "me"}' \
--json "{\"message\":{\"raw\":\"${RAW_B64}\"}}"
# Send a draft
gws gmail users drafts send --params '{"userId": "me"}' --json '{
"id": "<draftId>"
}'
# Delete a draft
gws gmail users drafts delete --params '{"userId": "me", "id": "<draftId>"}'
# List all labels
gws gmail users labels list --params '{"userId": "me"}'
# Create a label
gws gmail users labels create --params '{"userId": "me"}' --json '{
"name": "OpenClaw/NewLabel",
"labelListVisibility": "labelShow",
"messageListVisibility": "show"
}'
# Get label details
gws gmail users labels get --params '{"userId": "me", "id": "<labelId>"}'
# Mark as read (remove UNREAD)
gws gmail users messages modify --params '{"userId": "me", "id": "<messageId>"}' --json '{
"removeLabelIds": ["UNREAD"]
}'
# Star a message
gws gmail users messages modify --params '{"userId": "me", "id": "<messageId>"}' --json '{
"addLabelIds": ["STARRED"]
}'
# Archive (remove from INBOX)
gws gmail users messages modify --params '{"userId": "me", "id": "<messageId>"}' --json '{
"removeLabelIds": ["INBOX"]
}'
# Apply custom label
gws gmail users messages modify --params '{"userId": "me", "id": "<messageId>"}' --json '{
"addLabelIds": ["<labelId>"]
}'
gws gmail users messages batchModify --params '{"userId": "me"}' --json '{
"ids": ["<msgId1>", "<msgId2>"],
"addLabelIds": ["STARRED"],
"removeLabelIds": ["UNREAD"]
}'
# Trash (recoverable)
gws gmail users messages trash --params '{"userId": "me", "id": "<messageId>"}'
# Permanent delete (irreversible!)
gws gmail users messages delete --params '{"userId": "me", "id": "<messageId>"}'
# List threads
gws gmail users threads list --params '{
"userId": "me",
"q": "is:unread",
"maxResults": 10
}'
# Modify thread labels
gws gmail users threads modify --params '{"userId": "me", "id": "<threadId>"}' --json '{
"addLabelIds": ["STARRED"],
"removeLabelIds": ["UNREAD"]
}'
# Trash entire thread
gws gmail users threads trash --params '{"userId": "me", "id": "<threadId>"}'
# Get attachment (returns base64 data)
gws gmail users messages attachments get --params '{
"userId": "me",
"messageId": "<messageId>",
"id": "<attachmentId>"
}'
# Vacation responder
gws gmail users settings getVacation --params '{"userId": "me"}'
# Filters
gws gmail users settings filters list --params '{"userId": "me"}'
# Forwarding
gws gmail users settings forwardingAddresses list --params '{"userId": "me"}'
# Send-as aliases
gws gmail users settings sendAs list --params '{"userId": "me"}'
For any endpoint, check available parameters:
gws schema gmail.users.messages.list
gws schema gmail.users.messages.send
gws schema gmail.users.threads.get
Julia's inbox automation is split into two daily cron jobs:
| Label | Purpose |
|-------|---------|
| OpenClaw/Urgent | Time-sensitive, needs immediate attention |
| OpenClaw/Action | Requires response or action from Julia |
| OpenClaw/FYI | Informational, no action needed |
| OpenClaw/Financial | Bills, bank alerts, transactions |
| OpenClaw/Shopping | Orders, shipping, receipts |
| OpenClaw/Newsletters | Subscriptions, digests, promotions |
| OpenClaw/Social | Social media notifications, invites |
NEVER send an email unless the user has explicitly asked you to send it. Drafting an email is not permission to send it. Approving a draft is not permission to send it. The user must clearly and unambiguously instruct you to send before you call the send endpoint. When in doubt, ask. This applies to all accounts and all recipients — no exceptions.
gws CLI for Gmail — do NOT use himalaya, mutt, mail, or any other email CLI. The gws CLI handles multi-account auth and is the only supported tool.gws outputs JSON by default — parse directly or pipe through jqraw field for send/drafts uses base64url encoding — always use Python base64.urlsafe_b64encode (shell printf | base64 | tr corrupts ! and other special chars)format: "metadata" without metadataHeaders, validate the response, and select only the needed headers locallytools
Use exact configured Reolink cameras through the local Home Hub for availability and power status, fresh stills, visual commentary, protected Dylan/Julia/household sharing, and reversible spotlight control. Supports trusted owner tasks and explicitly scoped proactive automations; not for Nest or Ring cameras, arbitrary recipients, recordings, account changes, or raw camera APIs.
data-ai
Privately manage Dylan and Julia's household plant inventory and care history by physical location, bed, and exact Flower Cam view. Use for confirmed plant onboarding from camera conversations, camera- or bed-filtered inventory, record corrections, individual or whole-bed care, and private filtered exports. Pair with reolink-camera when an owner asks about plants visible in Flower Cam images.
testing
Inspect and control the physically secured Reachy Mini at Crosstown through ClawBody. Use for requests to check Reachy, look around, express an emotion, play any official emotion or dance preset, speak proactively, mute or unmute its microphone, stop movement, or describe what its camera sees.
tools
Handle Reachy/iMessage handoffs, selective durable memory, forgetting, and diagnostics; automatic context comes from the gateway plugin.