The Executive Assistant Agent

The Executive Assistant (EA) is a personal agent that works your own mailbox, calendar and Teams on your behalf β€” triage and write mail, read your inbox, "do your booking" (schedule / reschedule / cancel meetings), and read your teams' channels and your chats. It also helps you manage your notification preferences.

The EA never uses standing, application-wide Graph access. Instead it asks for access to your mailbox, calendar and Teams only when it first needs them, and only you can grant it (one Teams scope needs a tenant admin's consent once β€” see the Azure setup below):

  1. You ask the EA to do something with your mail/calendar (e.g. "what's on my calendar tomorrow?").
  2. If you haven't connected yet, the tool replies with a connect link (/auth/ea/connect) instead of acting. 🚨 For an agent that answer is the instruction, not an obstacle: hand the user {BaseUrl}/auth/ea/connect and wait for them to consent. Never route around it by minting a token of your own β€” a credential the user did not grant for this is not a substitute for the one they did.
  3. You click it β†’ Microsoft shows a consent screen for the EA's delegated scopes β†’ you approve.
  4. The portal stores your refresh token encrypted (AES-GCM via the deployment master key) as an EaCredential node and from then on mints short-lived delegated access tokens to call Graph as you (/me/…). You can revoke anytime in your Microsoft account.

Because access is delegated and per-user, the EA can only ever touch your data β€” there is no app-wide grant that could reach anyone else's mailbox.

What it can do

The EA agent declares the Mesh + ExecutiveAssistant plugins. The ExecutiveAssistant tool surface:

Area Tools
Mail ListInbox, SearchMail, ReadMail, DraftMail, DraftReply, GetDraft, UpdateDraft, DiscardDraft β€” and SendMail, ReplyToMail only where the deployment opted in, see below
Mailings PrepareMailing β€” one subject and body template merged per recipient into personal mails, saved as a page the person reviews; never sends β€” see below
Calendar ListEvents, GetEvent, CreateEvent (book + invite attendees), UpdateEvent, CancelEvent
Teams ListTeams, ListChannels, ReadChannelMessages, ListChats, ReadChat β€” the user's own organisation's teams and chats; and PostChannelMessage, ReplyToChannelMessage, SendChatMessage only where the deployment opted in (Teams:AgentSend=Send), see below

Example asks: "Book 30 min with Alice next Tuesday afternoon and invite her", "reply to the vendor that we accept", "clear my Friday", "email me when an approval needs me" (the last manages your notification rules).

πŸ”’ The agent DRAFTS and the human sends β€” Email:AgentSend

MailAgentOptions.SendMode is read from the configuration key Email:AgentSend and defaults to DraftOnly. In that mode the agent composes into your own Drafts folder with DraftMail / DraftReply, and you press Send in your mail client.

The safety here is structural, not a policy the model is asked to respect: in DraftOnly the send tools are never handed to the model at all, so no prompt, no injected instruction and no model misjudgement can reach a live send. A deployment that wants agents to send directly sets Email:AgentSend=Send; then SendMail / ReplyToMail appear in the tool list. If they are called in DraftOnly they refuse by name and tell the caller to use the draft tool instead.

What this means for an agent asked to "send an email": on a default deployment you cannot, and saying you will is wrong. Prepare the draft, then tell the person it is in their Drafts and what it says. The human-in-the-loop step is real and needs no extra UI.

No mail tool attaches a file

One limit of the mail surface, worth knowing before you promise an outcome: no ExecutiveAssistant mail tool attaches anything; the tools carry a text body only. A message that must carry a file goes through the document path instead β€” Share β‡’ as email in the node menu, i.e. SendDocumentDispatch.ExportAndSend with DocumentDelivery.Attachment β€” see Sending Email and the /share-email skill (get Skill/share-email, served from MeshWeaver.Plugins). It sends as the user, off the same EaCredential, so it needs no second consent.

Mailings β€” draft once, approve once, send to many

The same message to several people, each one personal, is a mailing, not N drafts. The agent's PrepareMailing tool takes one subject and one body template with placeholders ({{Name}}, {{FirstName}}, {{SenderName}}, and any key=value from a recipient line) plus the recipients as lines (email; name; key=value), files a Mailing node under the person, and answers with its links. It never sends: the person opens the page, checks each recipient's merged mail on Preview, and approves on Send β€” the mails then leave their own mailbox, one per recipient, with the outcome recorded per recipient.

The approve is structural, exactly as the draft step above: no agent tool can flip it, the Send button is offered only to the mailing's owner with a connected mailbox, and a mailing with any placeholder unresolved for any recipient refuses to send at all. The feature ships with the MeshWeaver.Mail.MicrosoftGraph module in Systemorph/MeshWeaver.Plugins; its README there is the reference.

Correcting a draft is an AMENDMENT, never a second draft

A draft has a whole life on this surface: DraftMail / DraftReply write one, GetDraft reads it back in full, UpdateDraft amends it in place (a Graph PATCH carrying only the fields you pass), DiscardDraft removes one that has been abandoned or replaced.

They exist for the reason GetEvent/UpdateEvent exist on the calendar half. Without them, the only correction available to an agent was to write a second draft beside the wrong one β€” and the reviewer then has to open Drafts and choose between two messages with the same recipient, the same subject, and one differing sentence. That is exactly the confusion the DraftOnly review step exists to remove, so the missing amend did not merely inconvenience the agent: it defeated the control. It surfaced on a real deadline submission (2026-08-31), where a German reply needed one question turned into a statement and the only options were to leave the wrong wording or to hand the person two drafts and instructions.

UpdateDraft REPLACES the whole body, exactly as UpdateEvent does β€” so read the draft with GetDraft first and send back everything you mean to keep.

All three are safe under DraftOnly and are offered there, which is the point: that is the mode whose correction path was broken. None of them sends. isDraft is not a writable property, the delivery routes (/sendMail, /messages/{id}/send, /reply) are never touched, and the human still presses Send.

🚨 The isDraft guard is re-read INSIDE the write

UpdateDraft and DiscardDraft must not decide "this is a draft" from the argument they were passed, nor from an isDraft the agent observed earlier. Each re-reads the message's current isDraft from Graph immediately before writing, and refuses when it is no longer one.

The reason is the design itself, not an exotic edge case. Under DraftOnly the human holds the other half of this workflow β€” the whole arrangement is that they press Send in their own mail client whenever they like. So an id that was an unsent draft when the agent read it can perfectly well have been sent by the person in that window. A guard evaluated from that earlier read would silently turn amend the draft into edit a message the recipient is already holding, and discard the draft into destroy sent mail β€” the stored record then disagreeing with what was delivered, with no trace of the difference. That is precisely the failure the /email skill's a sent message is READ-ONLY rule exists to prevent, reintroduced through the back door.

A refusal in that window is the correct outcome, and it says so plainly ("that message is no longer a draft … nothing was changed") so the agent reports it to the person rather than retrying.

A read and a write are still two round trips, so the re-read narrows the window rather than abolishing it. What closes the remainder differs between the two tools, and the difference is worth knowing before anyone edits them:

what stops a lost race
UpdateDraft the patch carries only subject, body, toRecipients, ccRecipients β€” the four fields Graph documents as "Updatable only if isDraft = true". A patch that arrives after the send is rejected by the server, atomically. importance, categories, flag and isRead are updatable on a sent message; adding one would remove that backstop silently, so the field set is pinned by a test.
DiscardDraft nothing beyond the re-read. DELETE /me/messages/{id} deletes whatever it is given, draft or sent β€” which is why this tool's guard is the only thing between "discard that draft" and destroying a delivered message.

Teams rides the same grant β€” reading always, posting where the deployment says so

The Teams tools run on the same delegated grant as mail and calendar (EaGraphAuth.Scopes carries Team.ReadBasic.All, Channel.ReadBasic.All, ChannelMessage.Read.All, Chat.Read since 2026-09-09, and ChannelMessage.Send, ChatMessage.Send since 2026-09-10). Three consequences the agent states rather than hides:

Editing an event is READ then PATCH, never cancel-and-recreate

GetEvent and UpdateEvent exist because their absence caused real data loss. With only ListEvents / CreateEvent / CancelEvent, "add one line to that meeting's agenda" had exactly one possible shape β€” cancel the event and create a new one β€” and ListEvents returned no body, so the agent could not read what it was replacing. On 2026-08-16 that wiped an eight-item checklist the user then had to re-dictate by hand; the original was unrecoverable (no mail, no mesh node, the cancelled event gone).

So the amend flow is GetEvent β†’ edit the returned body β†’ UpdateEvent: a Graph PATCH that carries only the fields you pass, leaving every omitted one at its stored value. CancelEvent means cancel the meeting, not change it. ListEvents now also returns a preview for quick triage, but a body you intend to REPLACE must be read in full with GetEvent first β€” UpdateEvent replaces the whole body, so whatever you do not send back is gone.

Architecture

The consent/credential half lives in the portal (it reuses the portal's Microsoft sign-in app + the master-key IProviderKeyProtector); ExecutiveAssistantPlugin itself ships in the MeshWeaver.Mail.MicrosoftGraph module (Systemorph/MeshWeaver.Plugins), which is why a deployment that sends no mail pays neither the 43 MB Graph/Kiota closure nor its Roslyn reference cost. The agent definition is Agent/ExecutiveAssistant.

Azure setup (one-time, by an admin)

The EA reuses the portal's sign-in app registration (the Authentication:Microsoft client). On it:

  1. Add the delegated Microsoft Graph permissions: Mail.ReadWrite, Mail.Send, Calendars.ReadWrite, Team.ReadBasic.All, Channel.ReadBasic.All, ChannelMessage.Read.All, Chat.Read, ChannelMessage.Send, ChatMessage.Send, offline_access.
  2. Add the redirect URI {BaseUrl}/auth/ea/callback (e.g. https://portal.example.com/auth/ea/callback).
  3. No admin pre-consent is required for mail and calendar β€” each user consents for themselves on first use (that's the point). ChannelMessage.Read.All is the one exception: it is admin-restricted, so a tenant admin grants it once (Enterprise applications β†’ the sign-in app β†’ Permissions β†’ Grant admin consent); until then non-admins see "Need admin approval".

No application-wide Graph permission is needed for the EA (the standing Calendars.ReadWrite application grant used by an earlier iteration can be removed; the shared memex@ ingestion mailbox keeps its Mail.ReadWrite / Mail.Send application permissions β€” those are a separate concern).

Privacy & revocation

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.