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.
Least-privilege by design: per-user, just-in-time consent
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):
- You ask the EA to do something with your mail/calendar (e.g. "what's on my calendar tomorrow?").
- 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/connectand 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. - You click it β Microsoft shows a consent screen for the EA's delegated scopes β you approve.
- The portal stores your refresh token encrypted (AES-GCM via the deployment master key) as an
EaCredentialnode 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 |
|---|---|
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:
- A grant consented for an earlier, smaller scope set cannot serve this build. Entra refuses to
redeem such a refresh token for the wider set, so the credential read classifies it as
not connected with a diagnostic that says the mailbox side is intact. The tool answers with the
connect link (
{BaseUrl}/auth/ea/connect), the agent hands it on β one reconnect, a few seconds β and the connect endpoint runs Microsoft's dialog for that user instead of bouncing a "connected" one straight back (the 2026-09-10 loop; see ExecutiveAssistantCredentialReads).ChannelMessage.Read.Allis admin-restricted: a non-admin sees "Need admin approval" until a tenant admin has consented once. - A team the user joined as a guest of another organisation is not visible. The grant is the
user's home tenant's;
/me/joinedTeamsomits guest teams and their channels answer 403/404. No scope changes that. The agent says so rather than reporting the team as missing. Reading such a team needs the other organisation's cooperation β see the repository's/teamsskill. - Posting is immediate. Teams has no draft state, so
PostChannelMessage,ReplyToChannelMessageandSendChatMessagego out the moment the model calls them. The gate is the same shape as mail's:Teams:AgentSenddefaults toOff, in which the posting tools are never handed to the model at all (and refuse by name if reached directly); a deployment setsTeams:AgentSend=Sendto hand them over. The scopes are consented either way, so turning posting on later does not cost every user a second reconnect. InSendmode there is no per-message confirmation β the same boundaryEmail:AgentSend=Sendhas, and the same approval-gate work closes both.
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
IEaGraphAuth/EaGraphAuthβ builds the consent URL, exchanges the auth code, stores/refreshes the encrypted per-user refresh token, and mints delegated access tokens.IEaGraphAuthis a test seam: tests substitute a fake so the consent step is mocked away (CI has no real auth).EaConsentControllerβ/auth/ea/connect(incremental-consent redirect) and/auth/ea/callback(code exchange + store). The acting user comes from the authenticated principal.ExecutiveAssistantPluginβ per call, fetches the user's delegated token and calls Graph/me/β¦; if the user hasn't connected, returns the connect link instead of acting.EaCredentialβ the encrypted refresh token, one per user underAuth/_EaCredential/{objectId}.
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:
- 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. - Add the redirect URI
{BaseUrl}/auth/ea/callback(e.g.https://portal.example.com/auth/ea/callback). - No admin pre-consent is required for mail and calendar β each user consents for themselves on
first use (that's the point).
ChannelMessage.Read.Allis 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
- The portal stores only your encrypted refresh token, never your password and never the raw token.
- The token is scoped to exactly the delegated permissions you approved.
- Revoke at any time from your Microsoft account (My Apps β the portal app β Revoke), or by deleting your
EaCredentialnode; the EA then falls back to asking you to reconnect.