Skip to content

Reading Gmail

Gmail is the single most common reason to reach for Ghostvault — once an account is signed in, you want to ask "what's in my inbox?" and get a usable answer. This guide covers the convenience tool, the robust fallback for when Gmail changes its DOM, and the SDK pattern.

Why this matters

Gmail is an aggressively dynamic SPA. The markup Google ships changes frequently — often month to month — and there is no stable public API for reading the inbox from a logged-in browser session. Ghostvault handles this two ways:

  1. gv_read_gmail — a convenience tool that parses the Gmail DOM for you and returns a tidy list of {id, subject, sender, snippet, url, date}. Fast and clean when it works.
  2. The fallback pattern — when the DOM shifts and the parser returns empty (or garbage), drop down to gv_open_url + gv_get_page_content(include_screenshot=True) and let the LLM read the page directly, using vision if needed.

You should expect to need both. Treat gv_read_gmail as the happy path and the screenshot fallback as your safety net.

Prerequisites

You need a managed account that is already signed in. If you haven't done that yet, see Getting Started → First sign-in.

1. "Create an account called Work (e.g. provider='google')"  → gv_create_account
2. "Sign in to Work"                                            → gv_sign_in (complete login + 2FA)
3. "Read my latest emails"                                      → gv_read_gmail   ← this guide

The happy path: gv_read_gmail

The convenience tool takes a single optional limit (default 20, max 50) and returns recent messages:

# SDK usage — returns up to 20 recent messages
result = await gv.read_gmail(limit=20)

Each message looks like:

{
  "id": "18f...",
  "subject": "Your weekly summary",
  "sender": "noreply@example.com",
  "snippet": "Here's what happened this week...",
  "url": "https://mail.google.com/mail/u/0/#inbox/18f...",
  "date": "2024-06-12T09:14:00Z"
}

This is all most tasks need. Hand it to the LLM and ask your question:

"Read my latest 10 emails and tell me which ones need a reply today."

The agent calls gv_read_gmail(limit=10), gets the list, and reasons over it.

The fallback: when gv_read_gmail returns empty

Gmail's DOM changes often. When the convenience parser can't find what it expects, it returns an empty list rather than garbage. The fix is to drive the page yourself:

# 1. Make sure the active account is the one you want
await gv.switch_account("acc_work123")

# 2. Open Gmail directly
await gv.open_url("https://mail.google.com")

# 3. Read the page text AND grab a screenshot so vision can help
result = await gv.get_page_content(include_screenshot=True, max_chars=20000)

The include_screenshot=True flag is important. Gmail renders a lot of its state visually (avatars, badges, the unread count, the promo/social tabs) that isn't reliably present in the text dump. With the screenshot attached, the LLM can actually see the inbox layout and report on it.

Gmail scraping is best-effort

There is no contract here. Google can ship a new Gmail version at any time and break both the convenience parser and the text extraction. The screenshot path is the most resilient because it relies on vision, not selectors. If you depend on Gmail reads in a script, always implement the fallback and treat an empty gv_read_gmail result as a signal to try it.

A robust two-step helper

Here's a pattern that tries the fast path first and falls back automatically:

import json

async def read_inbox(gv, limit=20):
    """Try the convenience tool, fall back to direct page reading."""
    # Fast path
    result = await gv.read_gmail(limit=limit)
    messages = _parse_messages(result)
    if messages:
        return messages, "gv_read_gmail"

    # Fallback: open Gmail + read with screenshot
    await gv.open_url("https://mail.google.com")
    await gv.get_page_content(include_screenshot=True, max_chars=20000)
    return None, "screenshot_fallback"   # caller lets the LLM read the screenshot


def _parse_messages(result):
    """Extract the messages list from a CallToolResult."""
    if hasattr(result, "content"):
        for block in result.content:
            if getattr(block, "type", None) == "text":
                try:
                    return json.loads(block.text).get("messages", [])
                except json.JSONDecodeError:
                    pass
    return []

Opening a specific message

gv_read_gmail returns a url per message (a deep link into the Gmail SPA). To read the body of one message:

# `url` came from a gv_read_gmail result
await gv.open_url("https://mail.google.com/mail/u/0/#inbox/18f...")
await gv.get_page_content(include_screenshot=True, max_chars=20000)

Again, grab the screenshot. Email bodies are where vision earns its keep — HTML email, attachments, and quoted replies all render visually.

A realistic conversation flow

Here's what a typical Gmail session looks like end to end, as you'd talk to it through Claude Desktop / Cursor / ZCode:

You:   "Read my latest 5 emails on Work and tell me if any need an urgent reply."

Agent: calls gv_switch_account("acc_work123")
Agent: calls gv_read_gmail(limit=5)
Agent: "You have 5 messages. Two look urgent:
        - 'Invoice overdue — Acme' from billing@acme.com
        - 'Action required: password reset' from security@github.com
        The rest are newsletters. Want me to open either?"

You:   "Open the invoice one."

Agent: calls gv_open_url("https://mail.google.com/mail/u/0/#inbox/18f...")
Agent: calls gv_get_page_content(include_screenshot=True)
Agent: "The invoice is $4,200, 12 days overdue. There's a Pay Now button.
        Want me to click it, or should I draft a reply to billing@acme.com?"

You:   "Draft a reply asking for a 7-day extension."

Agent: (drafts text, asks for confirmation before sending)

Note the pattern: read → summarize → drill in on demand. You almost never want to open every message up front — let the agent triage from the inbox list first.

Via the SDK

For scripts with no LLM in the loop, the SDK call is the same tool. Here's a minimal "print my unread senders" script:

import asyncio, json
from ghostvault_sdk import GhostvaultClient

async def main():
    async with GhostvaultClient() as gv:
        # Assumes gv_sign_in was already completed and the session persists.
        result = await gv.read_gmail(limit=20)
        for block in result.content:
            if getattr(block, "type", None) == "text":
                data = json.loads(block.text)
                for msg in data.get("messages", []):
                    print(f"{msg['sender']:40}  {msg['subject']}")
                break

asyncio.run(main())

One context per account

Ghostvault refuses to open two browser windows for the same account (it would corrupt the profile). If you're reading Gmail on Work, don't try to also open Work in another call — switch accounts instead. See Tool reference → Lifecycle.

Troubleshooting

Symptom Likely cause Fix
gv_read_gmail returns [] Gmail shipped a DOM change, or the session landed on a non-inbox view Fall back to gv_open_url("https://mail.google.com") + gv_get_page_content(include_screenshot=True)
"Session not authenticated" The Google session expired Re-run gv_sign_in for that account
Inbox opens but is empty You're on the wrong account, or Gmail is showing a promo tab Check gv_get_session_status; switch tabs via gv_click_element on "Primary"
Reading returns login page Profile lost its cookies Re-run gv_sign_in; consider encryption at rest if the profile was on a moved disk