Let agents hand off emails for review and send in the chat - #1067
Let agents hand off emails for review and send in the chat#1067time-attack wants to merge 7 commits into
Conversation
Add a send_email agent tool that files the finished email as a held item in the user's inbox ledger instead of sending it. The web UI renders that item inline in the conversation as a reviewable letter: preview the email as the recipient sees it, flip to edit To, Cc, Subject and body, then Send or Discard. Sending goes through the existing ledger action route and the Gmail adapter with the user's own token, carrying the draft revision it was based on so an agent redraft never silently overrides a human edit. The tool is only offered on web conversations where the user's Gmail is connected, so agents never see a send capability they cannot use. Compose items have no thread, so the Gmail adapter no longer prefixes "Re:" when an item carries no thread metadata. The HiLO approval flow was deliberately not used here: it replays the turn and has no channel for human edits, while the ledger already has edit, send, dismiss and optimistic concurrency. Ledger item routes on the web-ui server are now reachable by every signed-in user rather than only INBOX_USERS; core already restricts them to the loop's owner. Inbox item events fan out to every subscriber so the chat card refreshes when the item changes elsewhere.
The inbox sync playbook told the agent to close any held item whose latest message is from the user, which describes every compose draft; it now names compose items as outgoing mail under review and the task version bumps so existing crons pick the rule up. Compose items also stay out of the Inbox pane, which would otherwise show them as mail from the user to themselves, and the draft holder prunes the ledger the way the ingest route does so drafts do not accumulate for users without sync. The chat card serializes saves and sends on a per-item queue instead of dropping whichever arrives second: a save in flight no longer disables Send, text typed while a save lands is kept, and a Send clicked right after leaving a field waits for that field's save and reuses its revision. Conflicts only rebase the local edit inside the 409 handler, with the same wording the Inbox uses, and a draft that has left the ledger renders a static receipt from the tool result instead of an error. The From line reads the item's own sender.
The ledger now refuses a "send" on a compose item from any capability caller, so the tool's promise that only the person sends is enforced where every path flows through, while ordinary inbox replies keep their existing behaviour. The orchestrator offers send_email based on the Gmail connector token it actually resolved for the user rather than the env map, which keychain materialisation can also populate in shared scopes. OpenCode registers the tool definition so the per-turn enable map can reach it. Addresses in the "Name <addr>" form pass validation. The web-ui route test now expresses that ledger paths relay for any signed-in user with core deciding ownership.
|
Three fresh-context reviews (core/harness, web UI + server gate, ledger/Gmail semantics) ran against the first commit. Resolved in the two follow-up commits:
Noted, not changed: if only a company-type Google account is connected, the send goes out from it while the card shows the signed-in address as From. |
…dget The orchestrator no longer imports the draft holder; wiring injects a hold function and the orchestrator sees only the shared draft types, which now live in core types. Importing the holder from the orchestrator pulled the inbox loop, its Slack source, and the Slack payload module into the web-ui server and test typechecks, where the plugin's own copy of the Slack SDK does not typecheck them. The one-sentence protocol addition is dropped: it pushed the spine-channel prompt past its 1400-word ceiling, and the tool description plus the email skills already carry the same instruction. Prettier formatting for the files the previous commits left unformatted.
Live QA showed the card vanishing once the agent's turn settled: the live and settled messages are different lit templates, and a single card element interpolated into both is lost when the older tree is torn down. The card is now a pure template built from module state, the module publishes a version and a change hook, and the chat surface redraws on that hook and keys its settled-row cache on the version for messages that carry a draft.
send_email takes workspace paths in an attachments parameter. They are staged through the same outbound collector the attach tool uses, but on a staging instance of their own so they ride the email and not the chat reply. The draft keeps them as stored artifacts; the Gmail adapter loads the bytes at send time and builds a multipart/mixed message, capped at 5 MB in total so it fits the JSON send route. The card shows attachments as chips linking to the stored file, and edit mode can drop one. Every outgoing email is now multipart/alternative: a plain text part with the markdown markers removed and an HTML mirror that renders the light subset the tool advertises (bold, italic, code, links, simple lists), with everything else escaped. The card previews the body through the chat's markdown renderer so what the person reviews is what the recipient sees.
… card Review of the attachments commit found that the send path opened artifacts by bare id, so an edited draft could email any file in the system under an innocent name. The route now opens each attachment through the app's viewer-scoped file access as the loop owner, and the message carries the stored artifact's own name and type rather than the draft's claims. Drafts are sanitized through the shared parser when the tool files them, so a forged mimetype can never reach a MIME header, and filenames go out as RFC 2231 parameters with an ASCII fallback. Each send_email call stages its files on a fresh collector with its own seed: the shared instance derived the same artifact ids as the chat's attach tool, which could swap file contents between the reply and the email, and a second call with the same path deleted the first draft's artifact. Oversize attachments are refused on their declared size before any bytes are read, and the tool rejects more than ten. The markdown converter now lives in the chassis package so the card previews with exactly the HTML the recipient receives, with private-use sentinels that cannot be forged from the body, and the plain-text part applies the same block rules as the HTML. The prior copy was written with raw NUL bytes, which made the file read as binary.
|
Follow-up increment: the agent can attach workspace files ( A fresh review of that increment caught a real hole, fixed in the last commit: the send path opened attachment artifacts by bare id, so an edited draft could have emailed another user's file under an innocent name. Attachments are now opened as the loop owner through the viewer-scoped file access, and the artifact's own name and type are what go out. Also fixed: artifact id collisions between the chat's attach tool and the email staging (each email call now stages on its own seed), MIME header injection via a forged mimetype, RFC 2231 filenames, the size cap being checked after buffering, and a preview that rendered more than the email would. |
What
Agents can now hand a finished email to the person for review and send, right inside the chat.
send_emailagent tool. It sends nothing itself: it files the email as a held item in the user's Inbox ledger and returns adisplaypayload the web UI renders inline.Why not the HiLO approval card
The approval flow replays the whole turn and has no channel for human edits, and the model, not the approver, controls the re-issued arguments. The ledger already has edit, send, dismiss, reopen and optimistic concurrency, so the widget reuses that instead of growing a second approval path. The widget is also deliberately styled differently from "Approval needed": green "Ready to send" instead of a red reason badge, rendered prose instead of a command block, and Send / Discard instead of allow-once / allow-always.
Also in this change
replySubjectno longer prefixes "Re:" for items without Gmail thread metadata. Reply items are unchanged.INBOX_USERS. Core already restricts them to the loop's owner (canAdministerLoop,readableItems); the web-ui gate was a feature flag for the Inbox pane, not an authorization boundary.shared-core.md) and theemail-draft-in-voiceskill point agents atsend_emailwhen it is available.Deliberately not included
Screenshots
Real widget rendered against the app's CSS with mocked ledger data (preview, edit mode, after send, after discard).
Light: https://raw.githubusercontent.com/yc-software/qm/demo/email-review-screenshots/widget-light.png
Dark: https://raw.githubusercontent.com/yc-software/qm/demo/email-review-screenshots/widget-dark.png
Testing
test/email-draft.test.ts: the holder files a held gmail compose item; a compose item sends without threading headers or "Re:"; the tool validates input, paintsdisplay.emailDraft, is hidden when the turn cannot hold drafts and explains itself when Gmail is not connected.plugins/web-ui/test/email-draft.test.ts: extractor and source pins (chat hook, server gate, revision checks).plugins/web-ui/test/email-draft-card.test.ts: jsdom render of the real card: preview, edit and persist withexpectedProposalAt, a 409 redraft conflict that reloads and warns, then a successful send and receipt.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.