Skip to content

An agent cannot draft an email for a human to read — gmail_reply and gmail_send always go straight to the wire, and the gmail.modify scope a draft needs is already granted #765

Description

@serge-ivo

The problem

There is no way for an agent to prepare an outbound email for a human to look at. gmail_reply and gmail_send both go straight to the wire, and both say so:

workers/api/src/lib/connectors/gmail.ts:556 (gmail_reply) and :570 (gmail_send):

"This really sends: there is no draft step and no undo."

The honesty is right; the missing capability is not. #756 asked for it explicitly — "the preferred UX is draft/review by default for external replies with attachments, with explicit send when the user asks" — and it is the same request in the general case: an agent that has just read untrusted mail is composing a message that leaves under the owner's own name, to a real person, and cannot be recalled.

Priority — P2: correctness: no live defect; the send path is correct and consent-gated. This is a missing safety affordance on the most consequential tool on the platform.

Why this is smaller than it looks — VERIFIED

No new OAuth scope is needed for accounts that granted gmail.modify, and that scope is already requested and already recorded.

connectors/gmail.ts:506-519 requests four scopes, including:

			// gmail.modify (#716) — archive, mark read, relabel. There is no narrower scope for it.
			
			// Declared, not required. Google's consent screen lets a person grant send but decline
			// this, and only what was actually GRANTED is recorded
			"https://www.googleapis.com/auth/gmail.modify",

drafts.create is covered by gmail.modify. The predicate, the gate and the reconnect sentence all exist already:

  • scopesAllowModifyworkers/api/src/lib/gmail.ts:694-697
  • canModifyconnectors/gmail.ts:269-272
  • RECONNECT_TO_MODIFYconnectors/gmail.ts:284-287

So a draft tool reuses the gmail.modify gate verbatim. There is no re-consent migration of the kind #713 needed for gmail.send, and there is no fifth scope to request.

The MIME half is already built and shared: buildMimeMessage, replyHeaders, replySubject and collectOutgoingAttachments (connectors/gmail.ts:327-347) all produce the exact raw a draft takes. sendMessage (lib/gmail.ts:612-630) POSTs {raw, threadId} to /messages/send; a draft POSTs {message: {raw, threadId}} to /drafts. The delta is one endpoint and one wrapper object.

What to do — cheapest first

  1. gmail_draft_reply — identical to replyHandler (connectors/gmail.ts:358-403) up to and including the MIME, then drafts.create instead of messages.send. Gated by canModify; scope: "write" so the [connectors] Connector consent + write-scope safety + admin visibility #90 write-consent gate still applies. Returns the draft id and a console/Gmail link so the human can find it. This alone answers Epic: the emailed-form workflow stops at step 2 — download is broken (#755), Word is unreadable (#763), and only the byte-upload parameter is genuinely missing (#762) #756.
  2. gmail_draft_send — send a draft the human approved, by id. Gated by canSend (connectors/gmail.ts:274-282) exactly as sending is today.
  3. Make the choice legible in the tool descriptions. gmail_reply's description should point at the draft tool for anything the owner has not explicitly asked to be sent, the way inspect_pdf_form's description points at build_answer_sheet (pdf-storage-tools.ts:69).
  4. (Design, do not build blind) A default. Epic: the emailed-form workflow stops at step 2 — download is broken (#755), Word is unreadable (#763), and only the byte-upload parameter is genuinely missing (#762) #756 asks for draft-by-default on external replies with attachments. That is a policy question — a per-instance setting, a behaviour field, or an agent-declared default — and it is worth answering separately from the tools. State the recommendation and let the owner pick; my inclination is a per-instance setting rather than a hard default, because an agent whose whole job is replying should not need a human in the loop on every turn.

Alternatives considered and rejected

  • A confirm parameter on gmail_reply (confirm: "SEND"). Rejected: the confirming party is the model, not the human. A confirmation string a model can produce is not a review step, and it makes a reversible-looking tool irreversible on a token.
  • A board ticket carrying the send as an actionable approval (lib/actionable-ticket.ts, the runner-less approval gate). Genuinely attractive — it is the platform's existing human-gate primitive, and approving a ticket runs a declared action fixed at creation time. Rejected as the first step only because a Gmail draft is where a person already expects to review an email, from any device, with the attachments rendered. Worth revisiting for step 4's default: "draft it AND raise a ticket" is a better answer than either alone.
  • Reuse gmail.compose instead of gmail.modify. Rejected: gmail.compose is a fifth scope with its own consent line and re-connect story, and gmail.modify is already requested and already recorded per account. Adding a scope to avoid one already granted is a strictly worse trade.
  • Store the draft in PAGS and send later. Rejected: it puts the review surface somewhere the owner does not look, and the draft would not appear in Gmail's own Drafts on their phone.

Acceptance criteria

  • gmail_draft_reply on a mocked message creates a draft whose raw decodes to the same MIME gmail_reply would have sent for the same inputs — including In-Reply-To/References and the attachment_file_ids parts. Assert the MIME equality, so the two paths cannot drift.
  • It targets /drafts, not /messages/send — asserted on the request URL.
  • An account with gmail.readonly + gmail.send but not gmail.modify is refused with RECONNECT_TO_MODIFY, not with a raw Google 403.
  • The write-consent gate ([connectors] Connector consent + write-scope safety + admin visibility #90) refuses gmail_draft_reply for an instance without write consent on the gmail connector.
  • gmail_draft_send sends an existing draft and is refused by canSend when the account lacks gmail.send.
  • gmail_reply's and gmail_send's descriptions still contain the "there is no draft step and no undo" sentence for the direct path, and now name the draft alternative.

Regression risk

  • Recipient derivation must not be duplicated. replyHandler's recipients come from the parent message and never from the model — connectors/gmail.ts:349-356 calls that "a silent exfiltration channel out of the owner's own mailbox" if it were otherwise. A draft handler that re-implements that resolution instead of sharing it can drift into accepting a model-supplied to. Share the code path; the MIME-equality criterion is what catches a divergence.
  • Connector scope declaration. scopes.write is derived from the tools (manifest.ts:222-225), so adding two scope:"write" tools does not change the connector's declared reach — but connectors/registry.test.ts and the tool-count/mutation-report tests key off the tool list and will need updating.
  • Two more tools in every Gmail agent's prompt. The catalog is already large; if the draft tools are added, the direct-send descriptions must say clearly when each is right, or the model will pick by coin-flip.

Parent: #756. Related: #713 built the send path this mirrors · #716 added the gmail.modify scope this reuses · lib/actionable-ticket.ts is the alternative human gate considered above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workconnectorsConnector + tool frameworkenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions