Skip to content

Let agents hand off emails for review and send in the chat - #1067

Open
time-attack wants to merge 7 commits into
mainfrom
email-preview-widget-mockups
Open

Let agents hand off emails for review and send in the chat#1067
time-attack wants to merge 7 commits into
mainfrom
email-preview-widget-mockups

Conversation

@time-attack

@time-attack time-attack commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What

Agents can now hand a finished email to the person for review and send, right inside the chat.

  • New send_email agent tool. It sends nothing itself: it files the email as a held item in the user's Inbox ledger and returns a display payload the web UI renders inline.
  • The chat renders that item as a letter: preview it the way the recipient sees it, flip to Edit for To, Cc, Subject and body, then Send or Discard. After send it collapses to a one-line receipt; after discard, a receipt with Reopen.
  • Send goes through the existing ledger action route and the Gmail adapter with the user's own token. Every edit and send carries the draft revision it was based on, so an agent redraft never silently overrides a human edit (409 reloads and tells the user).
  • The tool is offered only on web conversations where the user's Gmail is connected, so agents never see a send capability they cannot use. Elsewhere the existing Gmail-drafts skill path still applies.
  • Live updates: inbox item events now fan out to every subscriber, so the card refreshes when the item is sent or changed from the Inbox pane or by the agent.

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

  • Compose items have no thread, so replySubject no longer prefixes "Re:" for items without Gmail thread metadata. Reply items are unchanged.
  • Ledger item routes on the web-ui server are reachable by every signed-in user rather than only 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.
  • Prompt guidance (shared-core.md) and the email-draft-in-voice skill point agents at send_email when it is available.

Deliberately not included

  • "Ask agent to change" button: typing in the composer is that path.
  • Split Send button (send later, save to Gmail drafts), attachments, and a Slack surface for the card. On Slack the tool is not offered.

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

light

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, paints display.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 with expectedProposalAt, a 409 redraft conflict that reloads and warns, then a successful send and receipt.
  • Ran: root and web-ui typecheck, eslint, oxlint, knip, orchestrator and harness suites, agent-tools, loop send/routes/ledger, css-vars and inbox source tests.
  • Not exercised: a live Gmail send end to end. No Google OAuth client is configured on this machine, so the tool never appears in a local dev instance. The Gmail adapter path itself is unchanged apart from the subject rule and is covered by existing tests.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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.
@time-attack

Copy link
Copy Markdown
Collaborator Author

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:

  • The Inbox sync playbook would have closed compose drafts as "already replied" because their From line is the user. The playbook now names compose items as outgoing mail under review and the task version bumps to 4 so existing crons pick it up.
  • Compose items stay out of the Inbox pane rather than appearing as mail from the user to themselves. The chat card is their surface.
  • The draft holder prunes the ledger like the ingest route, so drafts do not accumulate for users without Inbox sync. A draft that has aged out renders a static receipt instead of an error.
  • The card serializes saves and sends per item: 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 save and reuses its revision. Conflicts rebase only inside the 409 handler and use the Inbox's wording.
  • The ledger refuses a "send" on a compose item from any agent capability, so "only the person sends" is enforced at the route every path flows through. Ordinary inbox replies are unchanged.
  • The tool is gated on the Gmail connector token the orchestrator actually resolved, not on the env map.
  • OpenCode registers the send_email definition so the per-turn enable map can reach it.
  • Name <addr> recipients pass validation. Skill guidance no longer tells agents to both show the text and call the tool.

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.
@time-attack

Copy link
Copy Markdown
Collaborator Author

Follow-up increment: the agent can attach workspace files (attachments on send_email, up to 10, 5 MB total) and the body supports light markdown (bold, italic, code, links, simple lists) that goes out as multipart/alternative with a plain-text part and an HTML mirror. The card previews the body through the same converter the email uses and shows attachments as chips that can be dropped in edit mode.

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant