You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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.
gmail_draft_send — send a draft the human approved, by id. Gated by canSend (connectors/gmail.ts:274-282) exactly as sending is today.
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).
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.
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.
The problem
There is no way for an agent to prepare an outbound email for a human to look at.
gmail_replyandgmail_sendboth go straight to the wire, and both say so:workers/api/src/lib/connectors/gmail.ts:556(gmail_reply) and:570(gmail_send):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-519requests four scopes, including:drafts.createis covered bygmail.modify. The predicate, the gate and the reconnect sentence all exist already:scopesAllowModify—workers/api/src/lib/gmail.ts:694-697canModify—connectors/gmail.ts:269-272RECONNECT_TO_MODIFY—connectors/gmail.ts:284-287So a draft tool reuses the
gmail.modifygate verbatim. There is no re-consent migration of the kind #713 needed forgmail.send, and there is no fifth scope to request.The MIME half is already built and shared:
buildMimeMessage,replyHeaders,replySubjectandcollectOutgoingAttachments(connectors/gmail.ts:327-347) all produce the exactrawa 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
gmail_draft_reply— identical toreplyHandler(connectors/gmail.ts:358-403) up to and including the MIME, thendrafts.createinstead ofmessages.send. Gated bycanModify;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.gmail_draft_send— send a draft the human approved, by id. Gated bycanSend(connectors/gmail.ts:274-282) exactly as sending is today.gmail_reply's description should point at the draft tool for anything the owner has not explicitly asked to be sent, the wayinspect_pdf_form's description points atbuild_answer_sheet(pdf-storage-tools.ts:69).Alternatives considered and rejected
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.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.gmail.composeinstead ofgmail.modify. Rejected:gmail.composeis a fifth scope with its own consent line and re-connect story, andgmail.modifyis already requested and already recorded per account. Adding a scope to avoid one already granted is a strictly worse trade.Acceptance criteria
gmail_draft_replyon a mocked message creates a draft whoserawdecodes to the same MIMEgmail_replywould have sent for the same inputs — includingIn-Reply-To/Referencesand theattachment_file_idsparts. Assert the MIME equality, so the two paths cannot drift./drafts, not/messages/send— asserted on the request URL.gmail.readonly+gmail.sendbut notgmail.modifyis refused withRECONNECT_TO_MODIFY, not with a raw Google 403.gmail_draft_replyfor an instance without write consent on thegmailconnector.gmail_draft_sendsends an existing draft and is refused bycanSendwhen the account lacksgmail.send.gmail_reply's andgmail_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
replyHandler's recipients come from the parent message and never from the model —connectors/gmail.ts:349-356calls 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-suppliedto. Share the code path; the MIME-equality criterion is what catches a divergence.scopes.writeis derived from the tools (manifest.ts:222-225), so adding twoscope:"write"tools does not change the connector's declared reach — butconnectors/registry.test.tsand the tool-count/mutation-report tests key off the tool list and will need updating.Parent: #756. Related: #713 built the send path this mirrors · #716 added the
gmail.modifyscope this reuses ·lib/actionable-ticket.tsis the alternative human gate considered above.