# @kentcdodds/google — agent notes Human setup, intent, and the Gmail self-send gotcha live in `README.md`. This file is for agents: import paths, smoke / dryRun execute snippets, edge cases, and fork setup. OAuth tokens are managed by Kody integrations — never paste access tokens into chat. ## Auth / account names | Account alias | OAuth integration | Typical use | | --- | --- | --- | | `personal` | `google` | Personal Gmail, Drive, default YouTube reads | | `business` | `google-business` | Workspace Calendar + `me@kentcdodds.com` Gmail | | `youtube-brand` | `google-youtube-brand` | Main YouTube (`main` / Better with Kent) | | `youtube-plus` | `google-youtube-plus` | Plus / BEPE channel (`plus`) | Connect at `/connect/oauth`. Every helper requires explicit `account`. ## Import paths | Export | Import | | --- | --- | | package overview + `smokeTest` | `kody:@kentcdodds/google` | | accounts | `kody:@kentcdodds/google/accounts` | | core (`requestGoogle`, `getUserInfo`) | `kody:@kentcdodds/google/core` | | gmail | `kody:@kentcdodds/google/gmail` | | gmail-mime (UTF-8 / header-only helpers) | `kody:@kentcdodds/google/gmail-mime` | | calendar | `kody:@kentcdodds/google/calendar` | | drive | `kody:@kentcdodds/google/drive` | | people | `kody:@kentcdodds/google/people` | | youtube | `kody:@kentcdodds/google/youtube` | | youtube-upload-video | `kody:@kentcdodds/google/youtube-upload-video` | | youtube-update-video | `kody:@kentcdodds/google/youtube-update-video` | | youtube-analytics | `kody:@kentcdodds/google/youtube-analytics` | | youtube-channels | `kody:@kentcdodds/google/youtube-channels` | | types | `kody:@kentcdodds/google/types` | Prefer product helpers over raw `requestGoogle`. Prefer `youtube-upload-video` / `youtube-update-video` when invoking via package default-export surfaces. ## Smoke test (read-only) ```js import { smokeTest } from 'kody:@kentcdodds/google' export default async function main() { return await smokeTest({ account: 'personal' }) // => { account, profile: { email, name, emailVerified } } } ``` Also try `account: 'business'` when Workspace Gmail/Calendar is in scope. ## dryRun / mutation snippets Calendar list (read): ```js import { listEvents } from 'kody:@kentcdodds/google/calendar' export default async function main() { return await listEvents({ account: 'business', maxResults: 5 }) } ``` Gmail reply draft (preview — preferred before live draft): ```js import { createReplyDraft } from 'kody:@kentcdodds/google/gmail' export default async function main() { return await createReplyDraft({ account: 'business', replyToMessageId: '19fdd464c417fd87', body: ['Hi Adam,', 'Thanks for offering to record it.', '- Kent'], cc: 'sponsor-kcd@10xn.dev', dryRun: true, }) } ``` Calendar create (preview): ```js import { createEvent } from 'kody:@kentcdodds/google/calendar' export default async function main() { return await createEvent({ account: 'business', dryRun: true, event: { summary: 'Example', start: { dateTime: '2026-09-10T10:00:00-06:00' }, end: { dateTime: '2026-09-10T10:30:00-06:00' }, }, }) } ``` ## Idempotent Gmail sends Pass a stable `idempotencyKey` per logical message on `sendMessage`. Same `(account, idempotencyKey)` within **7 days** returns the prior successful send result from package storage instead of sending again. Use a new key for intentional follow-ups. Drafts and `dryRun` leave fingerprints untouched. ```js import { sendMessage } from 'kody:@kentcdodds/google/gmail' export default async function main() { return await sendMessage({ account: 'business', to: 'someone@example.com', subject: 'Welcome', body: ['Hi,', 'Welcome aboard.', '- Kent'], dryRun: true, idempotencyKey: 'expiry-batch-2026-09-11:user-42:welcome', }) } ``` ## Edge cases - **Replies:** prefer `createReplyDraft({ replyToMessageId, body })` (or raw MIME + explicit `threadId`) — not bare composed `sendMessage`. Composed `sendMessage` / `createDraft` require a real `subject` string (never omit / undefined / `"undefined"` / `"null"`). If any of `threadId` / `inReplyTo` / `references` is set, both `threadId` and `inReplyTo` are required so Gmail does not silently open a new thread. - **Self-send trap:** replying to Kent's own outbound mail must not To-only-self. `createReplyDraft` defaults To to original recipients; composed `createDraft` / `sendMessage` refuse To-only-self unless `allowSelfSend: true`. See README for the human-facing explanation. - Always pass `account` explicitly. Missing/unknown aliases throw. - Use `dryRun: true` before Gmail sends, Calendar mutations, label changes, and YouTube uploads/status updates when validating inputs. - Treat HTTP 403 as possible OAuth scope / API enablement issues. - YouTube: resolve via `resolveYouTubeChannel` / `youtube-channels` (`main` → `youtube-brand`, `plus` → `youtube-plus`) before upload or analytics. - Cal.com booking pages/lifecycle belong in `cal-com`, not this package. - `listEventsAcrossCalendars` is not an unbounded scan. It defaults to a **20s** wall-clock budget (explicit `budgetMs` is capped at **60s**) and returns the events collected so far with `truncated: true` and `truncatedReason: 'budget'`. Hidden calendars stay included unless `showHiddenCalendars: false`. Pass a tighter `timeMin` / `timeMax` when you only need a window. There is no flag to scan forever. - Inline images on replies are capped by `maxInlineImageBytes`; skipped ones appear as `skippedInlineImages` on dry-run / result. ## Gmail drafts: never rebuild body with bare `atob` **Incident:** updating a draft via `users.drafts.update` after `atob` (Latin-1 per byte) then re-encoding as UTF-8 turned Mimestream en-dashes (U+2013, bytes `E2 80 93`) into mojibake (`–` / `—`) and broke bullet lines. **Do this instead (safe path = default):** 1. **Header-only for Bcc/Cc/To/Subject** — prefer `updateDraftHeaders`. It patches headers on the raw MIME and **leaves body bytes unchanged**. ```js import { updateDraftHeaders } from 'kody:@kentcdodds/google/gmail' export default async function main() { return await updateDraftHeaders({ account: 'business', id: 'r-DRAFT_ID', bcc: 'me+log@kentcdodds.com', dryRun: true, }) } ``` 2. **If the body must be rewritten** — use UTF-8-safe helpers only: - `decodeUtf8Base64` / `decodeUtf8Base64Url` (`Uint8Array` + `TextDecoder`) - `encodeUtf8Base64` / `encodeBytesToBase64Url` (`TextEncoder` + bytes) - `decodeBase64ToBytes` when you need exact bytes (no string decode) - `createDraft` / `updateDraft` (composed) already emit `charset="UTF-8"` - `decodeBase64Latin1_DO_NOT_USE_FOR_MIME` always throws on purpose **Do NOT:** - `atob(draftRaw)` then `btoa` / `TextEncoder` back into `users.drafts.update` - Rebuild MIME from Latin-1 strings for a BCC-only change - Call Gmail `drafts.update` with a hand-decoded body when `updateDraftHeaders` would suffice Exports also live on `kody:@kentcdodds/google/gmail` (re-exported) and `kody:@kentcdodds/google/gmail-mime`. ## Fork / setup for agents 1. This package is Kent-account-aware. Fork only when the caller needs their own Google account map / OAuth wiring — then rewrite aliases and integrations. 2. Ensure the needed OAuth integrations are connected at `/connect/oauth`. 3. Smoke-test with `smokeTest({ account })` from `execute` (read-only profile). 4. Keep `community-icon.svg` unless deliberately updating branding. 5. Do not disable related webhooks/jobs when editing this package. ## Input schema (youtube-upload-video / youtube-update-video) `./youtube-upload-video` and `./youtube-update-video` (and the matching `youtube().uploadVideo` / `updateVideo` helpers) parse with Remix Schema (`unknownKeys: 'error'`). Upload aliases: `videoUrl` ← `video_url` / `url` / `mediaUrl` / `sourceUrl`; `title` ← `name`; `privacyStatus` ← `privacy_status` / `privacy`. Update aliases: `id` ← `videoId` / `video_id`. Unknown keys get did-you-mean hints. Prefer `dryRun: true` — never confirm uploads without explicit user approval. ## Runtime npm dependency note `package.json#dependencies.remix` (`3.0.0`) is intentional: source imports `remix/data-schema`. Do not move it to `devDependencies` unless a republish cannot persist runtime bundle artifacts.