Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

← All guides

Gmail drafts without send — lock what Google cannot scope

Official Kody guide

Google's Gmail API has a send-only scope and no drafts-only scope. Creating a draft requires gmail.compose ("Manage drafts and send emails") or a wider write grant. The token can send. A locked Kody package is the grant that cannot.

This is the usual complaint: an assistant should prepare the reply to an invoice or a support thread and leave it in Drafts. The human opens Gmail, edits, and sends. OAuth cannot say that. The published export can — and the publish lock keeps a later agent from widening it.

What lock does

A package is the declared-authority unit: named exports, jobs, and other package-owned surfaces run the published tree. An integration is auth only. The Google token stays as wide as Google issued it.

Publish lock (locked_at on the saved package) keeps serving that published tree. Agents and the five-minute reconcile job cannot advance published_commit. package_update accepts changes: { locked: true }. Agents cannot unlock; send the owner to /account/packages/:packageId.

Lock does not revoke send on the token, hide createAuthenticatedFetch from execute, or stop a different unlocked package from calling send. It stops this package's jobs and exports from silently becoming a sender.

Usage detail: Packages → Publish lock.

The loop

  1. Name the grant. "Create a draft reply. Never send." Write that in README ## Intent and in the export JSDoc Purpose. If the user later asks to send, that is a new grant — unlock on the website, change Intent, and publish with a send export only after they confirm.
  2. Connect Google with the narrowest token that can draft. Load integration_bootstrap, oauth, and provider_google. Request https://www.googleapis.com/auth/gmail.compose and add gmail.googleapis.com to allowedHosts. Add https://www.googleapis.com/auth/gmail.readonly only when the agent must read the inbox to propose the reply. Do not request https://mail.google.com/ or gmail.modify.
  3. Smoke-test draft create, not send. After authorize, call users.drafts.create from execute (example below). Confirm a draft appears in Gmail. Do not call users.messages.send or users.drafts.send.
  4. Save a thin drafts-only package. Follow package_authoring and package_lifecycle. Give it its own kody.id (for example gmail-drafts). Do not lock a full @kody/google fork if that fork exports send — keep send off this package's published surface. Search Purpose must say the export creates a draft and does not send.
  5. Publish, then lock. After checks pass and published_commit moves, call package_update with changes: { locked: true } (or the lock icon on /account/packages/:packageId). Say so in chat so the owner knows later publishes need their Promote this commit click.

Draft-create export

This is the write the package is allowed to perform. Encode an RFC 2822 message as raw and POST it to https://gmail.googleapis.com/gmail/v1/users/me/drafts. Never add /messages/send or /drafts/send.

import { createAuthenticatedFetch } from 'kody:runtime'

function toBase64Url(text: string): string {
	const bytes = new TextEncoder().encode(text)
	let binary = ''
	for (const byte of bytes) {
		binary += String.fromCharCode(byte)
	}
	return btoa(binary)
		.replaceAll('+', '-')
		.replaceAll('/', '_')
		.replace(/=+$/g, '')
}

function rfc2822Raw(input: {
	to: string
	subject: string
	body: string
}): string {
	const message = [
		`To: ${input.to}`,
		`Subject: ${input.subject}`,
		'MIME-Version: 1.0',
		'Content-Type: text/plain; charset="UTF-8"',
		'',
		input.body,
	].join('\r\n')
	return toBase64Url(message)
}

/**
 * Create a Gmail draft. Use when the human will review and send in Gmail.
 * Does not send.
 *
 * @param input - Recipients, subject, and plain-text body
 * @returns Gmail draft id
 *
 * @example
 * import createDraft from 'kody:@scope/gmail-drafts/create-draft'
 *
 * const draft = await createDraft({
 *   to: 'billing@acme.example',
 *   subject: 'Re: Invoice 1842',
 *   body: 'Thanks — I will review and reply today.',
 * })
 */
export default async function createDraft(input: {
	to: string
	subject: string
	body: string
}): Promise<{ draftId: string }> {
	const googleFetch = await createAuthenticatedFetch('google')
	const response = await googleFetch(
		'https://gmail.googleapis.com/gmail/v1/users/me/drafts',
		{
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({
				message: { raw: rfc2822Raw(input) },
			}),
		},
	)
	if (!response.ok) {
		throw new Error(
			`Gmail draft create failed: ${response.status} ${await response.text()}`,
		)
	}
	const data = (await response.json()) as { id?: string }
	if (!data.id) {
		throw new Error('Gmail draft create returned no draft id.')
	}
	return { draftId: data.id }
}

A job that drafts overnight follows the same rule: the scheduled wrapper calls this export only. It does not grow a send path "for convenience."

Later publishes

Pushes still land on Artifacts HEAD. Publish tools return locked with approval_url /account/packages/:packageId/approve-publish?commit=<sha>. The owner opens that URL and clicks Promote this commit. Promoting one commit does not unlock the package.

If an agent needs the lock off, it sends the owner to /account/packages/:packageId. It does not pass locked: false.

Same pattern on other coarse tokens

Use this guide whenever a provider token can do more than the published surface should. Slack workspace tokens, GitHub PATs with repo, and Drive-wide scopes have the same shape: grant the token you must, publish only the call you mean, lock so the creature cannot quietly grow.

When to load this guide

Load locked_gmail_drafts when someone wants Gmail drafts the agent must not send, when they ask why Gmail has no drafts-only scope, or when any OAuth token is coarser than the intended package. For the Google console and Testing-status refresh-token trap, load provider_google. For inbox reading as a teaching transcript, load google_oauth. For package shape and the lock field, load package_authoring and the publish lock usage page.

Working with an agent? This guide is also plain markdown at /guides/locked-gmail-drafts.md, or load it over MCP with coding_guide_get({ guide: 'locked_gmail_drafts' }).