Table of Contents generated with DocToc

Gmail drafting backends

The skills create Gmail drafts via one of two backends, selected by the user in config/user.md under tools.gmail.draft_backend:

BackendValuethreadId attach?Setup
claude.ai Gmail MCPclaude_ai_mcp (default)no (subject-matched fallback only)none — works as soon as the Gmail connector is authenticated on claude.ai
OAuth + curl scriptoauth_curlyesone-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

Both backends create drafts — never send. The human review-and-send step is still required before any outbound message leaves the user's Gmail.

Why there are two

The first-party claude.ai Gmail MCP is easy to set up and exposes everything the skills need for reading, searching, and listing drafts. It is the default.

The MCP‘s create_draft tool, however, does not accept a threadId parameter — a gap between the underlying Gmail REST API (which supports it on drafts.create) and the MCP’s surface area. As a consequence, drafts created via the MCP start a new conversation on the sender's Gmail side. Recipients' mail clients thread-match by subject (Re: <exact subject> + matching In-Reply-To / References chain) and usually attach the reply correctly on their side, but the sender sees a new conversation in their drafts queue. That makes pre-send auditing harder and means subsequent sync runs cannot rely on a single threadId to find every draft for a tracker.

The oauth_curl backend closes the gap by talking to the Gmail REST API directly, with a user-provided refresh token. It is opt-in because it requires a one-time OAuth setup (create a Google Cloud OAuth client, obtain a refresh token, drop it in a credentials file).

How the skills pick a backend

Every skill step that currently reads “create a Gmail draft via mcp__claude_ai_Gmail__create_draft actually means “create a draft via the project's preferred drafting backend, with oauth_curl always preferred when it is available”.

Precedence — oauth_curl wins whenever it is configured, regardless of what tools.gmail.draft_backend is set to. The point of having oauth_curl set up is threadId attachment; if the credentials are on disk, the skills should always use them. Resolution:

  1. Probe for oauth_curl credentials in this order:

    • tools.gmail.oauth_credentials_path from config/user.md when set;
    • the $GMAIL_OAUTH_CREDENTIALS environment variable;
    • the default path ~/.config/apache-steward/gmail-oauth.json.

    The probe is a single test -f <path> — actually parsing the file or doing a token-refresh probe at this stage would burn HTTP round-trips on every draft and is wasted work; if the file exists, trust it and let the script's first call surface any auth failure with a clear error.

  2. If credentials are found → use oauth_curl unconditionally. Invoke uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create with --thread-id, --to, --cc, --subject, --body-file — see oauth-draft/README.md for the full shape. threadId attachment is guaranteed; the draft surfaces in both the conversation view (recipient-side threading) and — when no pile-up blocks it — the global Drafts folder. The user's tools.gmail.draft_backend setting is ignored in this branch.

  3. If credentials are not found, fall back to claude_ai_mcp — call mcp__claude_ai_Gmail__create_draft. threadId attachment is unavailable; the draft uses the subject-matched path documented in threading.md.

  4. Hard explicit override: a user who has oauth credentials on disk but wants to force claude_ai_mcp for a specific reason (e.g. the pile-up workaround in the next section) sets tools.gmail.draft_backend: claude_ai_mcp_force (note the _force suffix). The plain claude_ai_mcp value is treated as “OK with either, prefer oauth_curl” — it is the default, so a user who explicitly set it that way had no oauth set up at the time and the precedence rule covers them automatically once they add credentials. The _force suffix is the explicit “never use oauth_curl” signal.

The skills surface which backend was used in the proposal / recap so the user can tell at a glance whether the draft threads on their own Gmail view or only on the recipient's. The format is one line:

Draft created via oauth_curl (threadId-attached on <thread-id-prefix>...)

or

Draft created via claude_ai_mcp (subject-matched fallback — oauth_curl credentials not found at default path; install via uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-setup to get threadId attachment)

The fallback line is intentionally noisy when oauth credentials are absent — a user who would benefit from threadId attachment should see the install hint on every draft creation until they set it up.

Detecting drafts that already exist on a thread

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). The detection rule depends on which backend created the prior draft:

  • claude_ai_mcp drafts live as standalone server-side conversations on the user's Gmail. Detect them via mcp__claude_ai_Gmail__list_drafts, optionally narrowed by query: "<recipient-email>" or a distinctive subject substring. Each MCP-created draft surfaces as a separate top-level entry in the Drafts folder.

  • oauth_curl drafts attach to the inbound thread by threadId. They carry the DRAFT label but may not surface in the global Drafts folder when multiple drafts pile up on the same thread (see the Known issue section below). Detect them by reading the thread directly:

    mcp__claude_ai_Gmail__get_thread(threadId: "<inbound-thread-id>", messageFormat: MINIMAL)
    

    and scanning the returned messages for any whose labelIds (or the snippet's metadata) include DRAFT. Every oauth_curl- created draft on a thread is reachable this way regardless of pile-up. list_drafts alone is not sufficient when oauth credentials are configured — always do the per-thread check too.

The composite rule: when a skill is about to draft a reply, run both detection paths (list_drafts + get_thread on the inbound threadId) and treat any hit as “a draft already exists; surface it to the user before drafting a new one”.

Limitations that apply to both backends

  • No update, no delete on the claude.ai MCP side — see 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.
  • Drafts are always drafts — both backends skip the send operation. A human review step is non-negotiable.
  • Confidentiality — both leave drafts in the user's personal Gmail account. The oauth_curl backend additionally requires the user to manage a refresh token on disk; treat it like an SSH key.

Known issue — oauth_curl thread-attached drafts may not surface in the global Drafts folder

Caught live on 2026-04-25 during the <tracker>#346 fix-skill flow: when multiple oauth_curl-backed drafts pile up on the same Gmail thread within a single skill flow (typical sequence: allocate-cve drafts a CVE-allocated message → sync-security-issue drafts a corrected version with updated state → fix-security-issue 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 appears to be a Gmail UI behaviour where multiple threadId- attached drafts on a single conversation collapse / hide in the global Drafts list rather than rendering as N separate entries. The drafts exist (a gh api repos/.../drafts round-trip confirms DRAFT labels and full message bodies); they are simply not navigable from the standard Drafts folder when stacked.

The MCP-backed claude_ai_mcp path does not have this problem because each draft lives on its own server-side conversation (no threadId attachment), so each draft is a separate top-level entry in the Drafts folder.

Recommended workflow when re-drafting on a thread that already carries a pending draft

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, prefer the claude_ai_mcp backend for the new draft — even when tools.gmail.draft_backend is set to oauth_curl. The trade-off:

  • Visibility wins: the new draft is guaranteed to surface in the user's Gmail Drafts folder, so they can actually see and review it.
  • Sender-side threading lost: the new draft will start a new server-side thread on the user‘s own Gmail (Evan’s mail client will still thread it onto the existing conversation via the Re: <exact subject> match, 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, oauth_curl remains the default — that draft is visible in both the conversation view and the Drafts folder.

Concrete steps when the pile-up has already happened

  1. Delete the stale oauth_curl drafts 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 oauth_curl are deletable via that same OAuth client; 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).
  2. Recreate the consolidated message via the claude.ai MCPmcp__claude_ai_Gmail__create_draft with the Re: <exact subject> line so it threads on the recipient's side via subject match.
  3. Surface the path change in the tracker's status rollup so the audit trail shows why the draft moved from oauth_curl to claude_ai_mcp — a future triager looking at the rollup should see “draft re-routed to claude_ai_mcp because oauth_curl pile-up was hidden from the Drafts folder” rather than wondering why the threading suddenly degraded.

When this rule does not apply

  • The thread has no pending draft yet — keep using oauth_curl (per the user's draft_backend config). This is the single-draft case and the visibility issue does not trigger.
  • The user is configured for claude_ai_mcp to begin with — no change. Subject-matched threading is the default for that backend; the recommendation above is specifically for oauth_curl users who hit a pile-up.

Migration — when the claude.ai MCP adds threadId

If a future version of the claude.ai Gmail MCP accepts threadId on create_draft, this backend split can retire. The probe that checks for this — see the scheduled routine under /schedule named gmail-mcp-threadid-probe — runs monthly. When it detects the new parameter, it comments on a tracking issue so we can swap the default back to claude_ai_mcp and deprecate this directory.

Referenced by