Connecting Gmail
Gmail is read (and, from the email.send skill, written to) through the real Gmail REST API over
OAuth — there’s no IMAP/SMTP fallback in the current build, unlike the “Gmail API when connected;
IMAP/SMTP fallback” language in PLAN.md §4; see the note at the bottom of this page.
Configuring the OAuth client
GOOGLE_CLIENT_ID=...GOOGLE_CLIENT_SECRET=...GOOGLE_REDIRECT_URI=http://localhost:4000/api/connections/gmail/callback # defaultCreate these in Google Cloud Console, add the redirect URI exactly (it must match verbatim), and
gmailConfigured() (apps/api/src/twin/gmail.ts) flips to true once both the client id and secret
are present. GET /api/twin/status reports providers[].gmail.configured from exactly this check,
with the hint “Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env” when it’s false.
The OAuth dance
GET /api/connections/gmail/auth → redirects to Google's consent screenGET /api/connections/gmail/callback → exchanges the code, saves the connection, redirects backThe auth route generates a random state nonce (10-minute TTL, held in memory) tied to your
beingId, and requests exactly two scopes: gmail.readonly and gmail.send, plus openid/email
to learn your address. access_type=offline + prompt=consent ensures a refresh_token comes back
even on a re-consent. The callback verifies the state, exchanges the code for tokens, fetches your
email address from Google’s userinfo endpoint, and saves everything — access token, refresh token,
expiry, your email — as the encrypted gmail connection, then redirects to
/settings?gmail=connected (or ...=error / ...=unconfigured) and kicks off a first sync
immediately.
Tokens refresh themselves transparently: accessTokenFor() checks if the access token has more than
60 seconds of life left; if not, it uses the refresh token to get a new one and re-saves the
connection. A refresh failure marks the connection status: 'error' with a plain message rather than
silently retrying forever.
What a sync does
POST /api/twin/sync/gmail → { started: true, provider: 'gmail' }syncGmail() lists your latest 50 threads (MAX_THREADS), skips any thread already turned into a
memory (tracked by source LIKE 'email:%'), and fetches the full content of up to 15 new ones per
sync (TWIN_MAX_EMAILS_PER_SYNC). Message bodies are parsed out of Gmail’s nested MIME parts
(preferring text/plain, falling back to a hand-stripped text/html), truncated to 3,000
characters.
One cheap-tier structured call summarises the whole batch at once — not one call per email — into:
{ threadId, summary, kind: 'document' | 'episode', importance, needsReply: boolean, replyHint, tags }kind is document for reference material (receipts, tickets) and episode for conversations;
importance follows a 0.8 / 0.4 / 0.15 scale for personal / routine / newsletter mail;
needsReply is deliberately conservative — true only when a real person is actually waiting on
you — because every true becomes a Signal on your desktop. Each summary is stored as a memory
with source: "email:<threadId>" (deduped by that source prefix, not by content, so a re-read of
the same thread never doubles up). Without an OpenRouter key, heuristicSummary() takes over:
automated-looking senders (no-reply, newsletter, notification, …) are demoted to low-importance
document memories, and needsReply only fires heuristically when the message is unread, not
automated, and contains a question mark.
Reply-needed signals
{ "kind": "decision", "title": "Reply to Jordan Alvarez?", "body": "\"Q3 numbers\" — They're asking whether Friday still works for the review.", "options": [ { "id": "reply", "label": "Reply", "hint": "Open Gmail" }, { "id": "draft", "label": "Draft for me", "hint": "Muse writes a draft" }, { "id": "ignore", "label": "Ignore" } ], "priority": "normal", "source": "email", "action": { "provider": "gmail", "threadId": "...", "messageId": "...", "from": "...", "subject": "Q3 numbers" }}See Signals and the omnibar for how these render and get answered.
Sending mail
The email.send skill (see Skills catalogue) calls sendGmail(), which
builds a raw RFC 2822 message (with In-Reply-To/References headers when replying inside a
thread), base64url-encodes it, and posts it to messages.send. Every send also writes a sent-kind
Twin activity.
A note on the PLAN.md gap
PLAN.md §4 and §6 describe Gmail with an “IMAP/SMTP fallback” for accounts without OAuth set up.
The current code has no IMAP/SMTP path at all — Gmail is OAuth-only; without
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, the connection simply stays unconfigured (configured: false with the plain-language hint above), rather than falling back to app-password IMAP/SMTP. This
is tracked as a real gap in Roadmap & FAQ.