Table of Contents generated with DocToc
oauth_curl is the preferred backendThe skills create Gmail drafts via one of two backends, selected by the user in .apache-magpie-overrides/user.md under tools.gmail.draft_backend:
| Backend | Value | Thread attach? | Setup |
|---|---|---|---|
OAuth + curl script | oauth_curl (strongly preferred — use this) | yes — via threadId (and explicit In-Reply-To / References headers) | one-time Google OAuth client + refresh-token setup, automated via uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-setup — see oauth-draft/README.md |
| claude.ai Gmail MCP | claude_ai_mcp (discouraged — do not use; see privacy warning) | yes — via replyToMessageId (a message ID resolved from the inbound thread) | none — works as soon as the Gmail connector is authenticated on claude.ai, but silently rewrites embedded URLs into Google tracking redirects (see below) |
The oauth_curl backend is also exposed as an MCP server (gmail-plaintext, tool create_draft) for agents that would rather call a tool than shell out — same plain-text-only guarantee, same credential file. See oauth-draft/README.md → MCP server.
Both backends create drafts — never send. The human review-and-send step is still required before any outbound message leaves the user's Gmail.
Use
oauth_curl. Do not use the claude.ai Gmail MCPcreate_draftfor any draft whose body contains URLs.
As of 2026-06-05, the claude.ai Gmail MCP create_draft tool silently rewrites every bare URL in the draft body into a Google tracking-redirect wrapper of the form:
https://www.google.com/url?q=<original-url>&source=gmail&ust=<timestamp>&sa=E
The rewrite is baked into the stored draft MIME — both the text/plain and text/html parts — not merely a display artifact (confirmed by reading the draft back via drafts.get?format=raw). So when the message is sent, the recipient receives the Google redirect instead of the link the skill wrote. This is unacceptable for the project's correspondence:
google.com/url, leaking click metadata (which recipient, which link, when) to a third party — on security-sensitive correspondence the project has no business funnelling through Google's redirector.google.com/url?q=... redirect onto a public advisory instead of the clean canonical URL.The oauth_curl backend builds the message with a plain RFC822 EmailMessage, so URLs are preserved verbatim — no rewriting, no third-party redirector. That is the decisive reason oauth_curl is the preferred backend for all drafting, not just the threadId / bulk cases.
If oauth_curl credentials are genuinely unavailable and a draft must be created via the MCP, the body must not contain URLs — inline the relevant text and tell the user to add the links by hand before sending, or (better) set up oauth_curl first.
oauth_curl is the preferred backendThe oauth_curl script talks directly to the Gmail REST API on a user-provided OAuth refresh token and builds its own MIME, which gives it three advantages over the claude.ai Gmail MCP:
threadId-keyed draft creation — attaches by threadId directly; for a brand-new, non-reply message, omit --thread-id and pass --no-reply-headers.oauth-draft-mark-read (label-modify on a query result set) has no MCP equivalent, and oauth_curl is the only backend that can delete drafts via the Gmail API.The one-time cost is a Google OAuth client + refresh-token setup (automated via oauth-draft-setup; see oauth-draft/README.md). Treat the credentials file like an SSH key. It is worth it: oauth_curl is the only backend that keeps the project's outbound links clean and untracked.
Every skill step that says “create a Gmail draft via mcp__claude_ai_Gmail__create_draft” is shorthand for “create a draft via the project's configured drafting backend” — which should be oauth_curl.
Preferred — oauth_curl. Resolution:
Probe for oauth_curl credentials in this order:
tools.gmail.oauth_credentials_path from .apache-magpie-overrides/user.md when set;$GMAIL_OAUTH_CREDENTIALS environment variable;~/.config/apache-magpie/gmail-oauth.json.The probe is a single test -f <path>.
Create the draft. Invoke uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create with --to, --cc, --subject, --body-file, and either --thread-id <threadId> (reply on an existing thread — the tracker stores threadId per the security-thread body field convention) or, for a brand-new message, --no-reply-headers with no --thread-id. See oauth-draft/README.md for the full shape. URLs in the body are preserved verbatim.
Last-resort fallback — claude_ai_mcp, only when oauth_curl credentials are unavailable. Subject to the hard constraint in the privacy warning above: the body must not contain URLs. When used:
mcp__claude_ai_Gmail__get_thread(threadId=<inbound>, messageFormat='MINIMAL') and take the id of the chronologically-last message.mcp__claude_ai_Gmail__create_draft(..., replyToMessageId=<that message id>), or omit replyToMessageId to fall back to subject-matched threading (see threading.md).oauth_curl credentials were missing, and that any links were omitted / must be added by hand. Do not silently swallow the configuration mismatch.The skills surface which backend was used in the proposal / recap so the user can tell at a glance how the draft is threaded:
Draft created via
oauth_curl(threadId-attached on<thread-id-prefix>...)
or, only when credentials were missing:
Draft created via
claude_ai_mcp(URLs omitted per privacy policy —oauth_curlcredentials not found; threaded via<replyToMessageId / subject-matched fallback>)
Before drafting a reply on a thread, skills check whether a pending draft already exists so they do not silently shadow it (the claude.ai MCP cannot update or delete drafts; see operations.md). Run both detection paths and treat any hit as “a draft already exists; surface it to the user before drafting a new one”:
List drafts globally. Call mcp__claude_ai_Gmail__list_drafts, optionally narrowed by query: "<recipient-email>" or a distinctive subject substring. Both claude_ai_mcp-with- replyToMessageId drafts and oauth_curl drafts surface here, as do legacy MCP drafts created without replyToMessageId (which live as standalone server-side conversations).
Read the thread directly. Call
mcp__claude_ai_Gmail__get_thread(threadId: "<inbound-thread-id>", messageFormat: MINIMAL)
and scan the returned messages for any whose labelIds (or the snippet's metadata) include DRAFT. This catches thread-attached drafts that — under the pile-up condition described below — may not be navigable from the global Drafts folder.
list_drafts alone is not sufficient when thread-attached drafts are involved (either backend); always do the per-thread check too.
text/plain) drafts, and must keep doing so. oauth_curl builds a single text/plain part via EmailMessage.set_content (its test suite asserts the message stays single-part text/plain with no text/html alternative). For claude_ai_mcp, this is guaranteed by populating only the body parameter and never htmlBody — passing htmlBody would add a text/html part. The project's correspondence (security replies, relays, advisory text) is plain text by policy; no skill should ever emit an HTML draft.operations.md — Hard limitation. The oauth_curl script could in principle update or delete drafts too (the Gmail API supports it), but the skills deliberately do not, to keep the drafts queue immutable and auditable.send operation. A human review step is non-negotiable.oauth_curl backend additionally requires the user to manage a refresh token on disk; treat it like an SSH key.Caught live on 2026-04-25 during the <tracker>#346 fix-skill flow: when multiple thread-attached drafts pile up on the same Gmail thread within a single skill flow (typical sequence: security-cve-allocate drafts a CVE-allocated message → security-issue-sync drafts a corrected version with updated state → security-issue-fix drafts the final version after a state change), the drafts all carry the DRAFT label in the Gmail API but only the most recent surfaces in the user‘s global Drafts folder in Gmail’s UI. The earlier ones become reachable only by direct URL or by opening the conversation view of the thread. The user‘s own report from that session: *"Can’t see the draft — I see some old drafts on the list but they are missing"*.
This is a Gmail UI behaviour where multiple thread-attached drafts on a single conversation collapse / hide in the global Drafts list rather than rendering as N separate entries. It applies to both backends — claude_ai_mcp drafts created with replyToMessageId and oauth_curl drafts attached by threadId — because both result in true thread-attached drafts on the Gmail server. The drafts exist (a Gmail API round-trip confirms DRAFT labels and full message bodies); they are simply not navigable from the standard Drafts folder when stacked.
The only path that avoids this is creating a draft without attaching it to the inbound thread — the legacy MCP behaviour before replyToMessageId was added. Each such draft becomes its own top-level entry in the Drafts folder, at the cost of losing sender-side threading.
When a skill is about to draft a reply on a thread that already has a pending draft on it from an earlier skill pass in the same session, omit the thread-attachment parameter for the new draft — i.e. call mcp__claude_ai_Gmail__create_draft without replyToMessageId (or oauth-draft-create without --thread-id). The trade-off:
Re: <exact subject> match plus the In-Reply-To / References headers Gmail synthesises, so the recipient experience is unaffected.The pile-up case is the only situation where this trade-off applies. For the first draft on a thread, the default thread-attached path remains preferred — that draft is visible in both the conversation view and the Drafts folder.
oauth_curl drafts can be deleted via the Gmail API (DELETE https://gmail.googleapis.com/gmail/v1/users/me/drafts/<draft-id> with the OAuth bearer token from the same oauth_curl credentials file). Drafts created via the claude.ai MCP can only be discarded from the Gmail UI (the MCP is no-update / no-delete per operations.md).mcp__claude_ai_Gmail__create_draft with replyToMessageId omitted and the Re: <exact subject> line so the recipient's client still threads it via subject match.replyToMessageId because the thread already carried a pending pile-up” rather than wondering why the threading suddenly degraded.replyToMessageId for claude_ai_mcp, --thread-id for oauth_curl). The single-draft case does not trigger the visibility issue.operations.md — per-backend call shape.threading.md — per-backend threading guarantees.tool.md — top-level Gmail tool overview.oauth-draft/README.md — the oauth_curl setup walkthrough.