Manage Gmail email, including drafting, sending, organizing, filters, vacation replies, and inbox analysis
assistant skills install gmailThis skill provides Gmail-specific operations beyond the shared messaging skill. For cross-platform messaging (send, read, search, reply), use the messaging skill. Gmail operations depend on the messaging skill's provider infrastructure - load messaging first if Gmail is not yet connected.
All operations use CLI scripts that return JSON:
{ "ok": true, "data": ... }{ "ok": false, "error": "..." }| Script | Operation | Description |
|---|---|---|
gmail-email.ts | draft | Create email drafts in the Drafts folder (including reply drafts) |
gmail-email.ts | send-draft | Send an existing draft (requires explicit user confirmation) |
gmail-email.ts | forward | Create forward drafts, preserving attachments |
gmail-email.ts | trash | Move messages to Trash |
gmail-manage.ts | label | Add or remove labels on messages |
gmail-manage.ts | follow-up | Track/untrack messages for follow-up using a dedicated "Follow-up" label |
gmail-manage.ts | attachments | List and download email attachments |
gmail-manage.ts | filters | Create, list, and delete Gmail filters |
gmail-manage.ts | vacation | Get, enable, or disable the vacation auto-responder |
gmail-manage.ts | unsubscribe | Unsubscribe from mailing lists (requires explicit user confirmation) |
gmail-scan.ts | sender-digest | Scan inbox and group messages by sender for declutter workflows |
gmail-scan.ts | outreach-scan | Identify cold outreach senders (no List-Unsubscribe header) |
gmail-archive.ts | archive | Archive messages (single, batch message_ids, cache_key+sender-emails, query) |
gmail-archive.ts | archive --dry-run | Preview what would be archived without executing (writes staged ops to log) |
gmail-archive.ts | archive --resume | Resume an interrupted archive run from its last checkpoint |
gmail-commit.ts | commit | Execute all staged ops from a dry-run |
gmail-commit.ts | cancel | Delete a run log without executing anything |
gmail-runs.ts | list | List recent operation runs with status summaries |
gmail-runs.ts | inspect | Show detailed log entries for a specific run |
gmail-runs.ts | prune | Delete operation logs older than 30 days |
gmail-reverse.ts | --run-id | Reverse all committed ops in a run (un-archive, un-label, un-trash) |
gmail-reverse.ts | --run-id --thread | Reverse a specific message within a committed run |
gmail-prefs.ts | list | List blocklist and safelist preferences |
gmail-prefs.ts | add-blocklist | Add sender emails to the blocklist |
gmail-prefs.ts | add-safelist | Add sender emails to the safelist |
gmail-prefs.ts | remove-blocklist | Remove sender emails from the blocklist |
gmail-prefs.ts | remove-safelist | Remove sender emails from the safelist |
gmail-prefs.ts | get-management-config | Get inbox management config (stage, interrupt threshold, last run) |
gmail-prefs.ts | set-management-config | Update inbox management config (--stage, --interrupt-threshold, --last-run) |
# Draft an email
bun run scripts/gmail-email.ts draft --to "user@example.com" --subject "Hello" --body "Message body"
# Draft a reply in a thread
bun run scripts/gmail-email.ts draft --to "user@example.com" --subject "Re: Hello" --body "Reply body" --thread-id "18f..." --in-reply-to "18f..."
# Send an existing draft (REQUIRES user confirmation before execution)
bun run scripts/gmail-email.ts send-draft --draft-id "r123..."
# Forward a message
bun run scripts/gmail-email.ts forward --message-id "18f..." --to "recipient@example.com" --text "FYI"
# Trash a message
bun run scripts/gmail-email.ts trash --message-id "18f..."
# Label - add a label to a message
bun run scripts/gmail-manage.ts label --message-id "18f..." --add-labels "Work,Important"
# Follow-up - track a message for follow-up
bun run scripts/gmail-manage.ts follow-up --action track --message-id "18f..."
# Attachments - list attachments on a message
bun run scripts/gmail-manage.ts attachments --action list --message-id "18f..."
# Attachments - download a specific attachment
bun run scripts/gmail-manage.ts attachments --action download --message-id "18f..." --attachment-id "ANGj..." --filename "report.pdf"
# Filters - create a Gmail filter
bun run scripts/gmail-manage.ts filters --action create --from "newsletter@example.com" --remove-labels "INBOX"
# Vacation - enable auto-responder
bun run scripts/gmail-manage.ts vacation --action enable --message "I'm out of office until Monday"
# Unsubscribe from a mailing list (REQUIRES user confirmation before execution)
bun run scripts/gmail-manage.ts unsubscribe --message-id "18f..."
# Sender digest - scan inbox grouped by sender
bun run scripts/gmail-scan.ts sender-digest [--query "in:inbox category:promotions newer_than:90d"]
# Outreach scan - identify cold outreach senders
bun run scripts/gmail-scan.ts outreach-scan [--time-range "90d"]
Scan scripts store message IDs in the assistant's cache and return a lightweight summary with a cache_key. This keeps thousands of message IDs out of the conversation context. To retrieve cached message IDs for a specific sender:
assistant cache get <cache_key> --json
# Returns: { "ok": true, "data": { "sender@example.com": ["msgId1", "msgId2", ...], ... } }
# Archive using cache_key + sender emails (preferred for declutter workflows)
bun run scripts/gmail-archive.ts archive --cache-key "scan:abc123" --sender-emails "news@example.com,promo@example.com"
# Archive specific message IDs
bun run scripts/gmail-archive.ts archive --message-ids "18f1...,18f2...,18f3..."
# Archive a single message
bun run scripts/gmail-archive.ts archive --message-id "18f..."
# Archive by query
bun run scripts/gmail-archive.ts archive --query "from:newsletter@example.com in:inbox"
All destructive archive operations are logged to a JSONL operation log for resumability and auditing. Each batch of archives is tracked as a "run" with a unique ID.
# List recent runs with status summaries
bun run scripts/gmail-runs.ts list [--limit 20]
# Show detailed log entries for a specific run
bun run scripts/gmail-runs.ts inspect --run-id "run_20260420_a1b2c3d4"
# Delete logs older than 30 days
bun run scripts/gmail-runs.ts prune
Dry-run mode runs the full pipeline (scanning, collecting message IDs) but skips all destructive API calls. Staged entries are written to the op log for review.
# Preview what would be archived
bun run scripts/gmail-archive.ts archive --query "..." --dry-run
# Review the staged operations
bun run scripts/gmail-runs.ts inspect --run-id "<run-id>"
# Commit the staged operations (executes the archive)
bun run scripts/gmail-commit.ts commit --run-id "<run-id>"
# Cancel (delete the log, nothing executed)
bun run scripts/gmail-commit.ts cancel --run-id "<run-id>"
Label and filter operations also support --dry-run:
bun run scripts/gmail-manage.ts label --message-ids "..." --add-labels "..." --dry-run
bun run scripts/gmail-manage.ts filters --action create --from "..." --remove-labels "INBOX" --dry-run
Archive operations now return a run_id in their output. Use this to resume interrupted runs:
# Resume an interrupted run (e.g. after daily quota hit)
bun run scripts/gmail-archive.ts archive --resume "run_20260420_a1b2c3d4"
# Pass --run-id to group multiple archive calls under one run
bun run scripts/gmail-archive.ts archive --query "..." --run-id "run_20260420_a1b2c3d4" --phase "noise_archive"
When a run is interrupted (e.g. Gmail daily quota exceeded), the operation log records the interruption with a resume hint. The assistant should detect interrupted runs and offer to resume them rather than starting fresh.
If a committed run archived messages incorrectly, reverse it:
# Reverse all committed operations in a run (requires confirmation)
bun run scripts/gmail-reverse.ts --run-id "run_20260420_a1b2c3d4"
# Reverse a specific message within a run (no confirmation needed)
bun run scripts/gmail-reverse.ts --run-id "run_20260420_a1b2c3d4" --thread "18f..."
Reversal semantics:
gmail-manage.ts filters --action deleteReversals are themselves logged as runs (auditable, resumable). The reversal only touches labels/state that the original run modified — it does not touch user-applied labels.
# List all preferences
bun run scripts/gmail-prefs.ts --action list
# Add senders to blocklist
bun run scripts/gmail-prefs.ts --action add-blocklist --emails "spam@example.com,junk@example.com"
# Add senders to safelist
bun run scripts/gmail-prefs.ts --action add-safelist --emails "important@example.com"
# Remove from blocklist
bun run scripts/gmail-prefs.ts --action remove-blocklist --emails "spam@example.com"
# Remove from safelist
bun run scripts/gmail-prefs.ts --action remove-safelist --emails "important@example.com"
When the user mentions "email" - sending, reading, checking, decluttering, drafting, or anything else - always default to the user's own email (Gmail) unless they explicitly ask about the assistant's own email address (e.g., "set up your email", "send from your address", "check your inbox"). The vast majority of email requests are about the user's Gmail, not the assistant's @vellum.me address.
Do not offer the assistant's own email as an option unless the user specifically asks. If Gmail is not connected, guide them through Gmail setup.
assistant oauth status google. This checks whether the user's Google account is connected and the token is valid.vellum-oauth-integrations skill. The skill will evaluate whether managed or your-own mode is appropriate and guide the user accordingly.When a Gmail script fails with a token or authorization error:
assistant oauth ping google. This often resolves expired tokens automatically.vellum-oauth-integrations skill. The user came to you to get something done, not to troubleshoot - make it seamless.Two operations are high-risk and require explicit user confirmation before execution:
send-draft: Always show the user what will be sent (recipients, subject, body summary) and wait for explicit confirmation ("yes", "send it", etc.) before running the send-draft script. The script gates on assistant ui confirm — it will present a confirmation dialog to the user and only proceed if approved. Never auto-send.unsubscribe: Always tell the user which mailing list will be unsubscribed from and wait for explicit confirmation before running the unsubscribe script. The script gates on assistant ui confirm. Unsubscribe actions cannot be undone.If the user has not explicitly confirmed, do not execute these operations. A general instruction like "clean up my inbox" is not confirmation to unsubscribe - it means scan and present options.
Gmail uses a draft-first workflow. All compose and reply operations create Gmail drafts automatically:
bun run scripts/gmail-email.ts draft creates a draft in Gmail Draftsbun run scripts/gmail-email.ts draft with --thread-id and --in-reply-to creates a threaded reply draft
bun run scripts/gmail-email.ts forward creates a forward draft, preserving attachmentsTo actually send: Use bun run scripts/gmail-email.ts send-draft with the draft ID after the user has reviewed it. Only run send-draft when the user explicitly says "send it" or equivalent.
When replying to or continuing an email thread:
thread_id — it automatically handles threading, reply-all recipients, and subject lines.bun run scripts/gmail-email.ts draft with both --thread-id and --in-reply-to for full control. The thread_id places the draft in the correct Gmail thread; in_reply_to sets the RFC 822 threading headers.rfc822MessageId in message metadata (looks like <CABx...@mail.gmail.com>). This is the value to pass as --in-reply-to. You can also pass a Gmail message ID directly — the draft script auto-resolves it to the RFC 822 header.When searching Gmail, the query uses Gmail's search operators:
| Operator | Example | What it finds |
|---|---|---|
from: | from:alice@example.com | Messages from a specific sender |
to: | to:bob@example.com | Messages sent to a recipient |
subject: | subject:meeting | Messages with a word in the subject |
newer_than: | newer_than:7d | Messages from the last 7 days |
older_than: | older_than:30d | Messages older than 30 days |
is:unread | is:unread | Unread messages |
has:attachment | has:attachment | Messages with attachments |
label: | label:work | Messages with a specific label |
When a user asks to declutter, clean up, or organize their email - start scanning immediately. Don't ask what kind of cleanup they want or request permission to read their inbox. Go straight to scanning - but once results are ready, always present them and let the user choose actions before archiving or unsubscribing.
CRITICAL: Never archive, unsubscribe, or take similar bulk actions unless the user has explicitly confirmed for that specific batch. Each batch of results requires its own explicit user confirmation. If the user says "keep going" or "keep decluttering," that means scan and present new results - NOT auto-archive. Previous batch approvals do not carry forward, but deselections DO carry forward: when the user deselects senders from a cleanup batch, run bun run scripts/gmail-prefs.ts --action add-safelist with those sender emails. Before building the next cleanup table, run bun run scripts/gmail-prefs.ts --action list and exclude safelisted senders from the table — the user already indicated they want to keep those.
Before starting category-specific cleanup, understand the inbox:
bun run scripts/gmail-scan.ts sender-digest --query "in:inbox" with --max-senders 75. This surfaces the top senders across ALL categories — not just promotions.hasUnsubscribe: true) → handle in promotions passdevops@, alerts@, noreply@) → handle in general noise passbun run scripts/gmail-scan.ts sender-digest --query "in:inbox -category:promotions -category:personal" to catch mailing lists, reminder chains, and automated notifications that aren't categorized as promotions. Present as a table following the same pattern as the promotions pass. Pre-select automated/noise senders, deselect anything that looks like real correspondence.bun run scripts/gmail-scan.ts sender-digest. Default query targets promotions currently in the inbox from the last 90 days (in:inbox category:promotions newer_than:90d). The script returns a cache_key plus a lightweight sender summary (counts, unsubscribe availability, sample subjects). Message IDs are stored in the assistant's cache — do NOT ask for them unless needed for archiving. Counts shown in the table reflect only what is currently in the inbox — these are the emails that will be archived.selectionMode: "multiple":
**Unsub? cell values**: Use rich cell format: { "text": "Yes", "icon": "checkmark.circle.fill", "iconColor": "success" } when hasUnsubscribe is true, { "text": "No", "icon": "minus.circle", "iconColor": "muted" } when false.
selected: true) - users deselect what they want to keepcache_key from the scan result in each button's data field. This ensures cache_key is forwarded automatically when the user clicks — the LLM does not need to recall it from earlier context:
{
"id": "archive_unsubscribe",
"label": "Archive & Unsubscribe",
"style": "primary",
"data": { "cache_key": "<cache_key value here>" }
}
action data: { cache_key, selectedIds }:
selectedIds are sender IDs (base64-encoded email addresses) — NOT Gmail message IDs. Always use them as sender emails with cache_key, never as message_ids.bun run scripts/gmail-archive.ts archive --skip-confirm once with --cache-key (from action data) + --sender-emails set to all selected sender emails. The script resolves message IDs from the cache and batches the Gmail API calls internally - never loop sender-by-sender. Use --skip-confirm because the user already confirmed via the UI table.bun run scripts/gmail-manage.ts unsubscribe for each sender that has hasUnsubscribe: true — but emit all unsubscribe calls in a single assistant response (parallel tool use) rather than one-at-a-time across separate turns.messageCount shown in the table matches the number of messages archived. Format: "Cleaned up [total_archived] emails from [sender_count] senders. Unsubscribed from [unsub_count]."bun run scripts/gmail-manage.ts filters --action create for each sender with --from set to the sender's email and --remove-labels "INBOX".After the newsletter/promotions pass, offer to clean up cold outreach — unsolicited emails from senders without unsubscribe links. This catches sales pitches, recruiting spam, and mass outreach that newsletter filters miss.
bun run scripts/gmail-scan.ts outreach-scan (default: last 90 days, senders without List-Unsubscribe headers). The scan includes a hasPriorReply flag per sender — true means the user has previously replied to that sender.hasPriorReply: true — these are conversations, not cold outreach. If the contacts skill is loaded, also cross-reference against Google Contacts and exclude matches.Key principle: Not all cold outreach is unwanted. Recruiting, investor, and partnership emails can be valuable. When uncertain, default to keeping the sender (deselected) and let the user decide.
When a scan returns truncated: true or timeBudgetExceeded: true, the inbox has more messages than a single scan pass can cover. Split subsequent scans by date range to ensure full coverage:
Pass 1: in:inbox older_than:90d (oldest backlog)
Pass 2: in:inbox newer_than:90d older_than:30d (recent months)
Pass 3: in:inbox newer_than:30d older_than:7d (recent weeks)
Pass 4: in:inbox newer_than:7d (this week)
Merge results from all passes before presenting the final table. Each pass covers a smaller window, reducing per-scan message count and avoiding timeouts. Only split when a scan actually reports truncation — most inboxes are handled fine in a single pass.
truncated is true, the top senders are still captured. Offer to run additional date-range passes to cover the remaining messages (see Large Inbox Handling above).timeBudgetExceeded: true, present whatever results were collected. Offer to run additional date-range passes for uncovered periods.The gmail-prefs.ts script persists sender preferences across cleanup sessions:
bun run scripts/gmail-archive.ts archive --query "from:email1 OR from:email2 ... in:inbox"). The user will see a confirmation prompt for the archive — once approved, the script proceeds.bun run scripts/gmail-prefs.ts --action list. If blocklisted senders exist, offer to auto-archive them first ("I have N previously archived senders — want me to clean those up first?"). Remove safelisted senders from scan results before presenting the table.bun run scripts/gmail-prefs.ts --action add-blocklist with the archived sender emails to persist them for future sessions.bun run scripts/gmail-prefs.ts --action add-safelist with the deselected sender emails.remove-blocklist or remove-safelist accordingly.When scan results include senders from the user's own company domain (e.g., @vellum.ai):
akash@company.com, aaron@company.com) — deselect these from cleanup tables.devops@, alerts@, noreply@, security@, billing@ at the company domain are automated forwards, not personal correspondence. These are archivable by default — pre-select them in the cleanup table.team@company.com), check sample subjects. Automated alerts, CI notifications, and vendor forwards → archivable. Direct messages with personal context → protect.Do not blanket-protect an entire domain. The user's company domain often generates more automated noise than any external sender.
Scan scripts (gmail-scan.ts sender-digest, gmail-scan.ts outreach-scan) return a cache_key that references message IDs stored in the assistant's cache. This keeps thousands of message IDs out of the conversation context.
cache_key + sender emails to bun run scripts/gmail-archive.ts archive instead of individual message_idsassistant cache get <cache_key> --jsonmessage_ids still work as a fallback for non-scan workflowsThe outreach-scan operation enriches each sender with hasPriorReply (whether the user has ever sent an email to that address). Use this signal to filter out legitimate correspondents before classifying cold outreach.
bun run scripts/gmail-archive.ts archive supports --cache-key + --sender-emails (preferred for declutter workflows), --message-ids, --message-id, or --query.
bun run scripts/gmail-manage.ts label supports --message-id or --message-ids only — it does not accept cache_key.
cache_key, then use the archive script to batch-archive by sender.bun run scripts/gmail-manage.ts attachments --action list --message-id "18f..." — returns filename, MIME type, size, and attachment ID for each attachment on a message.bun run scripts/gmail-manage.ts attachments --action download --message-id "18f..." --attachment-id "ANGj..." --filename "report.pdf" — saves a specific attachment to disk.Workflow: use attachments --action list to discover attachments, then attachments --action download to save them locally.
bun run scripts/gmail-scan.ts sender-digest to scan for newsletters and promotionsbun run scripts/gmail-manage.ts filters --action create --from "newsletters@example.com" --remove-labels "INBOX"bun run scripts/gmail-manage.ts vacation --action enable --message "I'm out of office until Monday"bun run scripts/gmail-manage.ts follow-up --action track --message-id "18f..."bun run scripts/gmail-manage.ts follow-up --action listbun run scripts/gmail-manage.ts follow-up --action untrack --message-id "18f..."bun run scripts/gmail-scan.ts outreach-scan to find senders without unsubscribe headersBefore composing any email that references a date or time:
current_time: field in the <turn_context> block for today's date and timezoneMedium and high risk operations require a confidence score between 0 and 1: