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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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