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
- Name the grant. "Create a draft reply. Never send." Write that in README
## Intentand 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. - Connect Google with the narrowest token that can draft. Load
integration_bootstrap,oauth, andprovider_google. Requesthttps://www.googleapis.com/auth/gmail.composeand addgmail.googleapis.comtoallowedHosts. Addhttps://www.googleapis.com/auth/gmail.readonlyonly when the agent must read the inbox to propose the reply. Do not requesthttps://mail.google.com/orgmail.modify. - Smoke-test draft create, not send. After authorize, call
users.drafts.createfromexecute(example below). Confirm a draft appears in Gmail. Do not callusers.messages.sendorusers.drafts.send. - Save a thin drafts-only package. Follow
package_authoringandpackage_lifecycle. Give it its ownkody.id(for examplegmail-drafts). Do not lock a full@kody/googlefork 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. - Publish, then lock. After checks pass and
published_commitmoves, callpackage_updatewithchanges: { 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.