Skip to content

AGENTS.md

214 lines · 8.2 KB · Markdown
Raw

@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 aliasOAuth integrationTypical use
personalgooglePersonal Gmail, Drive, default YouTube reads
businessgoogle-businessWorkspace Calendar + me@kentcdodds.com Gmail
youtube-brandgoogle-youtube-brandMain YouTube (main / Better with Kent)
youtube-plusgoogle-youtube-plusPlus / BEPE channel (plus)

Connect at /connect/oauth. Every helper requires explicit account.

Import paths

ExportImport
package overview + smokeTestkody:@kentcdodds/google
accountskody:@kentcdodds/google/accounts
core (requestGoogle, getUserInfo)kody:@kentcdodds/google/core
gmailkody:@kentcdodds/google/gmail
gmail-mime (UTF-8 / header-only helpers)kody:@kentcdodds/google/gmail-mime
calendarkody:@kentcdodds/google/calendar
drivekody:@kentcdodds/google/drive
peoplekody:@kentcdodds/google/people
youtubekody:@kentcdodds/google/youtube
youtube-upload-videokody:@kentcdodds/google/youtube-upload-video
youtube-update-videokody:@kentcdodds/google/youtube-update-video
youtube-analyticskody:@kentcdodds/google/youtube-analytics
youtube-channelskody:@kentcdodds/google/youtube-channels
typeskody:@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 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.
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,
	})
}
  1. 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.