feat(desktop): add propose_message and propose_email draft-and-you-send tools

SA
1d ago
+2939-35

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_message places a draft in the Messages app with the recipients and text filled in.
  • propose_email opens 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:), in app and app_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_message is the default for any request to text, message or iMessage someone;
  • send_message is only for when the person explicitly asks Omi to send it for them;
  • create_desktop_dispatch, send_message and propose_* 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) and text;
    • the "this does NOT send" contract.
  • Different on the Mac:
    • the Mac never reports sent or cancelled in practice. iOS gets sent or cancelled from its compose sheet. On the Mac (verified on macOS 27), .composeMessage shows 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_message has no subject;
    • the Mac adds propose_email.

Results

Every result carries sent_by_omi: false and a status:

  • handed_to_email_app: the email app named in app took 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.
  • cancelled and open (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. open is 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: canPerform false 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 originatingUserText with the clock preamble stripped, as isMemoryContentGroundedInUserRequest does. 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_contacts result 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 a qri_ 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_lookup with next_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_email and send_message now 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 calls send_message or create_desktop_dispatch for it.
  • A guard in send_message. In the next live run, the person typed "Send" after a draft, and the model called send_message with the same recipient and text. On main the kernel's approval check refused it, so nothing was sent, but the guidance alone did not hold. send_message now checks before any send. If the same owner handed a propose_message draft to Messages in the last 10 minutes, and the send's recipient matches any recipient of that draft, it returns not_sent / draft_in_messages and 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_open and 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 from desktop.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").

  • evaluateDesktopToolPolicy returns allow, but narrowly:

    • any other write bundle requested beside it keeps its own approval;
    • externalSend or persistentGrant still 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_message unless the person explicitly asked Omi to send;
    • use propose_email for 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_applescript and search_contacts now point at the propose tools.

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. NSSharingService has no CC field. A mailto: 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 safer propose_*. 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.systemPromptSuffix still tells the agent to drive Messages and Mail through osascript.

Product invariants

  • INV-AGENT-* (Authority). The changes are in desktop/macos/agent/src/runtime/**:

    • the new desktop.compose.propose bundle and its platform-confirmed classification in desktop-tool-policy.ts;
    • the bundle in the evaluate_desktop_tool_policy enums;
    • 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.ts covers the new authorization path.

  • 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) and ComposeSendGuardTests (6): 70 tests, all passing. They use a fake ComposeSurface, 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: executeSendMessage returns draft_in_messages before 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.
  • 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 forbids send_message and create_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 --check matches, the full agent vitest suite passes (72 files, 909 tests), and pi-mono-extension passes (143). The vitest cases cover:
    • the policy: allow without dispatch, deny without the bundle, any other write keeps approval, externalSend forces a dispatch;
    • routing: propose_message is the default for "text me at ''"; send_message no longer says "requires an approval dispatch" and says never to file one; create_desktop_dispatch says never to use it for a text, message or email; propose_email never assumes Mail and reports handed_to_email_app. The repo's only routing eval (eval-realtime-routing) covers the realtime voice lane, which does not offer propose_*, 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_message or create_desktop_dispatch for it; send_message must not send an open draft and names draft_in_messages; propose_message makes 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.
  • e2e: the hermetic device-tool-surface flow 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-lint passes.
  • swift-format and SwiftLint (0 violations) are clean; the desktop test-quality ratchet is at baseline.
  • Whole Swift suite: xcrun swift test --skip SubscriptionEntitlementServiceTests executed 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) and MeetingScreenEvidencePassTests.testRetryAfterFinalizeJoinsTheRunTheDeadlineLeftInFlight (timing). SubscriptionEntitlementServiceTests was 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_message attempt 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.

Email draft filled in, opened by the default mailto handler (Chrome, Outlook on the web)

Message draft placed in the Messages composer (no sheet on macOS 27)

Typing guard: Omi did not open the draft while the person was typing

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