feat(desktop): add propose_message and propose_email draft-and-you-send tools
What changed and why
Today "email Sam the deck" on the Mac has no proper path. list_mail_messages reads headers only, send_message covers iMessage only and needs an approval card, and the fallback is run_applescript. iOS already has propose_message: it opens the system compose sheet, filled in, and the person taps Send.
This PR adds two Mac tools in that spirit:
propose_messageplaces a draft in the Messages app with the recipients and text filled in.propose_emailopens a new email draft in the Mac's default email app with the recipients, subject and body filled in.
The person reviews the draft and presses Send in that window. Omi never sends: the tools use NSSharingService (.composeMessage, .composeEmail) and stop there. There is no AppleScript, no Mail or Messages automation, and no TCC prompt. Nothing is sent until the person presses Send, though Mail may autosave an open draft to the account's Drafts mailbox on the mail server.
Which app. .composeEmail hands the draft to whatever app handles mailto: links. That may be Mail, another mail app such as Outlook, or a browser, which opens its web mail site. The packaged-app test confirmed this: the tester's default was Google Chrome, and the draft opened in Outlook on the web.
- Results name the app, resolved with
NSWorkspace.urlForApplication(toOpen: mailto:), inappandapp_is_browser. - When the app is a browser, the result says the draft opened on its web mail site and that Omi cannot see whether it was sent.
- Nothing in the results, guidance or docs assumes Mail.
Routing. In the same live test, "text me at '…'" made the model file a create_desktop_dispatch approval for send_message. On main that dispatch has no card to render it, so the request dead-ended. The guidance is now explicit:
propose_messageis the default for any request to text, message or iMessage someone;send_messageis only for when the person explicitly asks Omi to send it for them;create_desktop_dispatch,send_messageandpropose_*all say a message or email is never pre-filed as a dispatch.
send_message's behaviour and the dispatch tool itself are unchanged.
This is piece 1 of the follow-through proposal in #21162. That issue is titled "…behind the approval card" and has no maintainer reply yet. This piece deliberately has no Omi approval card (see Policy below), and that is a decision for maintainers to confirm.
The PR goes straight to main. It does not depend on the approval-card stack (#20973 → #20975 → #20997 → #20998).
What is shared with iOS, and what differs
- Shared:
- the tool name
propose_message; to(an array) andtext;- the "this does NOT send" contract.
- the tool name
- Different on the Mac:
- the Mac never reports
sentorcancelledin practice. iOS getssentorcancelledfrom its compose sheet. On the Mac (verified on macOS 27),.composeMessageshows no sheet: it places the draft in the Messages app's own composer and reports the handoff within about half a second. Email apps do the same. So Omi cannot see whether the person sends or discards the draft; - the Mac returns as soon as the draft is handed off, instead of holding the turn until the person acts;
- Mac
propose_messagehas nosubject; - the Mac adds
propose_email.
- the Mac never reports
Results
Every result carries sent_by_omi: false and a status:
handed_to_email_app: the email app named inapptook the draft. macOS reports this before the person presses Send, so it never means sent.handed_to_messages: the draft is in Messages for the person to review and send. Whether they send or discard it cannot be observed.cancelledandopen(still on screen after the wait) are produced only if the system shows a sheet or an email app reports a cancel. The code still handles a sheet, but current macOS does not show one.openis also the answer when the turn is stopped after the handoff.not_opened: no window was opened. The reasons are:stopped: the call was stopped before the window opened;person_typing;draft_already_open: the same draft was handed off in the last two minutes;- (from
send_message, not a propose tool)draft_in_messages: a send to someone whose draft is waiting in Messages; nothing is sent; compose_already_open(only with a sheet);draft_limit_reached;compose_unavailable:canPerformfalse or "not configured";- a validation reason, listed below.
compose_failed is used for any other error from the share service and reports only the error code.
Recipients come from the person, not from what the model read
The compose window has no "Omi wants to…" framing. So the recipient rule from send_message ("never send to a handle the user did not name or confirm") is enforced in code, not only in the prompt.
Every recipient must come from one of:
- words the person typed, pasted or dictated in this turn. This is the run's
originatingUserTextwith the clock preamble stripped, asisMemoryContentGroundedInUserRequestdoes. For push-to-talk, the run text is the provider's transcript of what the person said, so dictation needs no separate path; - the person's one previous turn in the same main chat, so that "draft it to sam@x.com" followed by "yes, that one" works;
- a
search_contactsresult from the same run and owner; - the signed-in account's own address.
If a recipient fails that check, the tool returns recipient_needs_confirmation, lists the address, and opens nothing. This covers an address the model read on a page, in an email or on screen.
Limits of the check:
- Question-card answers never count. On a question card the person clicks a label, but the turn's text is the model-written
preparedAnswer. Those turns carry aqri_turn key in the chat transcript and are left out, both as the current turn and as the previous one. The run text is matched to its chat turn by content; when nothing matches (task chat, voice), the run text stands alone. - Pasted text counts as the person's. If they paste a thread and ask for a reply, its addresses are grounded. That is useful, but it means a pasted page can bring an address in, and the person still sees it in the To field.
- Spoken addresses don't match. "sam at example dot com" is not an address, so voice users reach people through Contacts.
- Phone numbers match as whole numbers. The digits must be equal, or one side may add a one-to-three-digit country code to a national number of ten or more digits. A date, the digits inside an address, or a number after "order", "invoice", "ticket", "#" and similar words never grounds a phone number.
Validation:
- a bare name returns
recipient_needs_lookupwithnext_tool: search_contacts; - addresses use a narrow ASCII form, which rejects
?,&,%,/,#,..and lookalike Unicode domains, while punycode is allowed; - phone numbers allow 3 to 15 digits;
- recipients are deduplicated, up to 20 per draft.
No handoff while the person is typing (load-bearing)
Return sends from the Messages composer. The live test confirmed it. A keystroke that lands in the draft can send it before the person has reviewed it, so this guard is a safety control, not a nicety:
- a draft is handed off only after 3 s with no key press, measured as the system-wide keyDown idle through
UserInputPresence; - the last idle check runs with no suspension before the handoff;
- the guard waits at most 8 s, and otherwise returns
not_opened/person_typing.
Residual risk: someone who starts typing again just after the handoff can still type into the composer.
"Send it" after a draft
In the live test, the person replied "send" after a draft. The model called propose_message again, which put three copies in Messages. Two fixes:
- Guidance.
propose_message,propose_emailandsend_messagenow say that after a draft the person presses Send in Messages or their email app. On "send" or "send it", the model tells them to do that. It never opens the same draft again and never callssend_messageorcreate_desktop_dispatchfor it. - A guard in
send_message. In the next live run, the person typed "Send" after a draft, and the model calledsend_messagewith the same recipient and text. Onmainthe kernel's approval check refused it, so nothing was sent, but the guidance alone did not hold.send_messagenow checks before any send. If the same owner handed apropose_messagedraft to Messages in the last 10 minutes, and the send's recipient matches any recipient of that draft, it returnsnot_sent/draft_in_messagesand sends nothing.- Matching uses the same normalisation as grounding, so spelling and country-code variants match. The text does not matter.
- A cancelled draft and an email draft do not count.
- The check is in Swift. With an approval card in front of
send_message(the separate approval stack), the card would still appear first. A kernel-side check would be needed to suppress the card.
- A Swift guard against duplicate drafts. An identical draft for the same owner within two minutes returns
draft_already_openand is not handed off again. Identical means the same channel, the same recipients in any order or spelling, and the same text. Only a SHA-256 digest is kept. A cancel or failure report clears it, so the person can ask for the same draft again.
Limits
All limits are per signed-in owner, and a sign-out releases them:
- One email draft and one message draft per request (run). "Email Sam the deck and text him" works, but a looping or injected model cannot stack compose windows inside one answer. The email app takes its window as soon as it has the draft, so this limit, not the window, is what bounds email.
- At most three drafts a minute, or one a minute when there is no run ID.
- The same draft at most once in two minutes (
draft_already_open). - Only if the system shows a Messages sheet: one open sheet at a time (
compose_already_open). A sheet that never reports stops blocking after 5 minutes.
Policy: no Omi approval card, and why
-
The tools get a new bundle,
desktop.compose.propose, separate fromdesktop.messaging.send. Selecting one never implies the other. -
It is a write bundle (risk tier medium, privacy
local_private), but it is platform-confirmed. Apple's window shows the person the exact recipients and text, and nothing is sent until they press Send. An Omi card in front of it would ask twice for the same effect. iOS takes the same position (requires_user_confirmation: "the compose sheet is the approval"). -
evaluateDesktopToolPolicyreturnsallow, but narrowly:- any other write bundle requested beside it keeps its own approval;
externalSendorpersistentGrantstill forces a dispatch;- the tools are not in the broker's enforced approval set.
-
Remaining risk without a card: the window looks like the person's own draft. Three things keep that risk bounded:
- recipients must come from the person;
- the limits above;
- the person must still press Send.
-
The tools are also restricted in three ways:
- Coordinator only: a background or pill worker can't call them.
- No ask-mode service runs: these runs have read-only authority.
- Never retried automatically: the invocations are non-idempotent, so they are
never_auto_retry.
-
Model guidance:
- prefer
propose_messageunless the person explicitly asked Omi to send; - use
propose_emailfor email; - never draft to a handle the person did not name or confirm;
- one draft per channel per request;
- say who the draft is addressed to.
send_message,run_applescriptandsearch_contactsnow point at the propose tools. - prefer
Privacy
The arguments are logged by shape only. The executor's own log line records counts and the outcome. Results echo the recipients, which the model supplied, and never the body.
Not in this PR, and questions
- CC/BCC.
NSSharingServicehas no CC field. Amailto:path would lose the outcome report and open whichever mail app is the default. - Attachments.
- A checked send. Confirming a handed-off draft in the mail account's Sent mailbox or the Messages history is the next piece of #21162.
- Pills (question for maintainers). Execute-mode pills are leaf runs. They can call
send_message(with its card) but not the saferpropose_*. Should pills get the propose tools? That would need a decision on raising windows from background work. - A pre-existing problem in this area.
ProactiveTaskExecute.systemPromptSuffixstill tells the agent to drive Messages and Mail through osascript.
Product invariants
-
INV-AGENT-*(Authority). The changes are indesktop/macos/agent/src/runtime/**:- the new
desktop.compose.proposebundle and its platform-confirmed classification indesktop-tool-policy.ts; - the bundle in the
evaluate_desktop_tool_policyenums; - two manifest entries.
Run/attempt capability checks are unchanged.
propose_*go through the same broker authorization and ledger preparation as every Swift tool, and the executor checks the owner before and after the window.run-tool-capability.test.tscovers the new authorization path. - the new
-
INV-CHAT-1. Nothing changes in the transcript. Proposal results are ordinary tool results in the kernel's invocation ledger. The only Swift state is in memory: the per-run contacts handles, the draft digests, the open-sheet slot and the limits. All of it is owner-scoped and released on sign-out, so it is not a second store.
Testing
xcrun swift build -c debug --package-path Desktop: passes.ComposeProposalCoordinatorTests(20),ComposeProposalTests(19),ComposeRecipientGroundingTests(12),ComposeEmailAppTests(9),ComposeDuplicateDraftTests(4) andComposeSendGuardTests(6): 70 tests, all passing. They use a fakeComposeSurface, a scripted keyboard, an injected sleeper and an injected clock, so no window opens and no test waits on the wall clock. They cover:- Results: email handoff; a report arriving mid-wait; no outcome ever
sent; every result having a status. - Limits: one draft per channel per request even after the email app took the first, one email plus one message allowed in a request; three a minute; one a minute with no run; an open sheet blocking the next request; a late report freeing the slot; the 5-minute stale release; a new owner and sign-out not inheriting the slot.
- Failure paths: unavailable and refused presentation; the not-configured and failure mappings.
- Stopping: a call stopped before the window opens opens nothing, at the coordinator and the executor; stopping after the window opens returns
open. - Typing guard: waiting for quiet; 2.5 s of quiet still counting as typing (3 s threshold); the handoff following the last idle check directly; continued typing opening nothing and leaving the request's draft unused.
- send_message after a draft:
executeSendMessagereturnsdraft_in_messagesbefore reaching Messages; any text to the same person in another spelling is refused; the normal path applies again after 10 minutes and for a different recipient or owner; an email draft or a cancelled draft does not block; every recipient of a group draft is covered. - Duplicates: the same draft refused within two minutes (including whitespace-only differences), then allowed again after; recipients matched in any order and spelling; an edited draft allowed; a cancelled draft can be asked for again; another owner is not a duplicate.
- Grounding:
- addresses the person typed (with the clock preamble present);
- an injected page address refused, at grounding level and end to end through the executor, with nothing presented;
- a question-card answer not counted, as the current turn or as the previous one, including end to end;
- the previous turn counting, and only one turn back, including a confirmation end to end;
- task chat and voice text standing alone;
- pasted text counting;
- contacts handles scoped to their run and owner, and released;
- the account's own address;
- whole-number phone matching (country code added or dropped, short codes exact, no partial or seven-digit tail);
- dates, order, invoice and ticket numbers, and digits inside addresses never grounding a phone number.
- Validation:
?cc=,%40,&bcc=,/,#, Cyrillic and fullwidth lookalike domains,.., a leading dot, a hyphen-led label and a one-letter TLD are all rejected; ordinary and punycode addresses are accepted; plus the earlier parsing cases. - Logging: the argument log never contains the address, subject or body.
- Results: email handoff; a report arriving mid-wait; no outcome ever
ComposeEmailAppTests(9): the Messages handoff result says the person presses Send in Messages and Omi cannot see the send, with no sheet claimed; a duplicate result points at the app and forbidssend_messageandcreate_desktop_dispatch. Also, with a fake handler for Mail, Microsoft Outlook and Google Chrome, plus an unknown handler:- results name the app and never assume Mail;
- a browser result says "web mail site" and "can't see whether it was sent";
- open, failed and unavailable results name the app;
- Messages results say Messages;
- classification: a known browser, or the app that opens web links, counts as a browser, and Outlook does not;
- the executor passes the resolved handler through.
scripts/test-tool-surfaces.sh:generate-tool-surfaces --checkmatches, the full agent vitest suite passes (72 files, 909 tests), andpi-mono-extensionpasses (143). The vitest cases cover:- the policy: allow without dispatch, deny without the bundle, any other write keeps approval,
externalSendforces a dispatch; - routing:
propose_messageis the default for "text me at ''";send_messageno longer says "requires an approval dispatch" and says never to file one;create_desktop_dispatchsays never to use it for a text, message or email;propose_emailnever assumes Mail and reportshanded_to_email_app. The repo's only routing eval (eval-realtime-routing) covers the realtime voice lane, which does not offerpropose_*, so it has no case for this; - after a draft: "send" or "send it" means telling the person to press Send; never open the same draft again; never call
send_messageorcreate_desktop_dispatchfor it;send_messagemust not send an open draft and namesdraft_in_messages;propose_messagemakes no claim about a sheet; - the manifest: "does NOT send", "did not name or confirm",
recipient_needs_confirmation, the same-run precondition,coordinatorOnly; - the broker: authorized without a grant,
never_auto_retry, absent from a leaf capability.
- the policy: allow without dispatch, deny without the bundle, any other write keeps approval,
- e2e: the hermetic
device-tool-surfaceflow now asserts that a compose proposal refuses a bare name, an empty draft and an address the person never gave, before any window could open.desktop-flow-lintpasses. - swift-format and SwiftLint (0 violations) are clean; the desktop test-quality ratchet is at baseline.
- Whole Swift suite:
xcrun swift test --skip SubscriptionEntitlementServiceTestsexecuted 7905 tests with 10 skipped. Four tests failed (5 assertions), all known and unrelated to this change:DesktopAutomationWindowPresentationTests(2 tests, display-dependent window geometry),RecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime(locale date format) andMeetingScreenEvidencePassTests.testRetryAfterFinalizeJoinsTheRunTheDeadlineLeftInFlight(timing).SubscriptionEntitlementServiceTestswas skipped because it crashes whole runs on this machine.
Packaged-app pass (a person, on the packaged build of this branch at e1894d91ed, production servers, macOS 27, placeholder recipients only):
- ✓ Email draft opened in the default mailto handler and was named correctly. The handler was Google Chrome, so the draft opened in Outlook on the web, filled in, with nothing sent.
- ✓ The Messages draft was handed to the Messages app's own composer. macOS 27 shows no sheet.
- ✓ The typing guard refused to hand off while the person was typing (
not_opened/person_typing). - ✓ A duplicate draft was refused (
draft_already_open, twice). - ✓ "send" / "send it" after a draft: the guidance and the duplicate guard held. One later
send_messageattempt was stopped by the kernel's approval check, so nothing was sent. That case is now also refused in Swift (draft_in_messages). - Also confirmed: Return sends from the Messages composer. That is why the typing guard matters.
UI evidence
Captured by a person (captured_by: human, source: manual). The metadata is in .agent-artifacts/ui-evidence/desktop-propose-compose/evidence.json.



Failure class (fixes)
No fix: commits; no declaration is required.
Line-Count-Exception: desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift | 3961 -> 3965 | the propose_message/propose_email dispatch case with its turn text and run id, and the run and owner passed to search_contacts so its handles can address a draft; parsing, grounding and result mapping live in ChatToolExecutor+ComposeProposals.swift and Services/Compose*.swift