@kentcdodds/google
Google helpers for Gmail, Calendar, Drive, People, YouTube (including upload), and Analytics.
AGENTS.md
214 lines · 8.2 KB · Markdown@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)
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):
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):
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):
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.
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 composedsendMessage. ComposedsendMessage/createDraftrequire a realsubjectstring (never omit / undefined /"undefined"/"null"). If any ofthreadId/inReplyTo/referencesis set, boththreadIdandinReplyToare required so Gmail does not silently open a new thread.
- explicit
- Self-send trap: replying to Kent's own outbound mail must not To-only-self.
createReplyDraftdefaults To to original recipients; composedcreateDraft/sendMessagerefuse To-only-self unlessallowSelfSend: true. See README for the human-facing explanation. - Always pass
accountexplicitly. Missing/unknown aliases throw. - Use
dryRun: truebefore 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. listEventsAcrossCalendarsis not an unbounded scan. It defaults to a 20s wall-clock budget (explicitbudgetMsis capped at 60s) and returns the events collected so far withtruncated: trueandtruncatedReason: 'budget'. Hidden calendars stay included unlessshowHiddenCalendars: false. Pass a tightertimeMin/timeMaxwhen you only need a window. There is no flag to scan forever.- Inline images on replies are capped by
maxInlineImageBytes; skipped ones appear asskippedInlineImageson 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):
- Header-only for Bcc/Cc/To/Subject — prefer
updateDraftHeaders. It patches headers on the raw MIME and leaves body bytes unchanged.
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,
})
}- If the body must be rewritten — use UTF-8-safe helpers only:
decodeUtf8Base64/decodeUtf8Base64Url(Uint8Array+TextDecoder)encodeUtf8Base64/encodeBytesToBase64Url(TextEncoder+ bytes)decodeBase64ToByteswhen you need exact bytes (no string decode)createDraft/updateDraft(composed) already emitcharset="UTF-8"decodeBase64Latin1_DO_NOT_USE_FOR_MIMEalways throws on purpose
Do NOT:
atob(draftRaw)thenbtoa/TextEncoderback intousers.drafts.update- Rebuild MIME from Latin-1 strings for a BCC-only change
- Call Gmail
drafts.updatewith a hand-decoded body whenupdateDraftHeaderswould suffice
Exports also live on kody:@kentcdodds/google/gmail (re-exported) and
kody:@kentcdodds/google/gmail-mime.
Fork / setup for agents
- This package is Kent-account-aware. Fork only when the caller needs their own Google account map / OAuth wiring — then rewrite aliases and integrations.
- Ensure the needed OAuth integrations are connected at
/connect/oauth. - Smoke-test with
smokeTest({ account })fromexecute(read-only profile). - Keep
community-icon.svgunless deliberately updating branding. - 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.