Personal triage
"i would like you to pre-triage all my emails and imessage and whatsapp and show me categories such as where i should reply, where i should take action or decide, the threads i am working on" · "especially triage out what you consider spam or uninteresting commercials, what you consider important to read, where action is required" · "this adds the most value to my life" · "try to distinguish between what's important for me vs what i can ignore" · "maybe add tags? similar to github issues?" — the maintainer, 2026-10-13.
Status: building — the live Outlook source, its triage verdicts and the opt-in Outlook copy are built (below). Decided with the maintainer on 2026-10-13: the triage model is the instance's EU model catalogue; messages are read live from their provider, never replicated ("it should take from graph, not necessarily replicate"); the side menu shows the triage on top, the kinds (sources) below.
What is stored, and what is not
| stored in the mesh | read live from the provider | |
|---|---|---|
| the message (body, attachments) | never — unless the person switches the opt-in copy on (below): then the body AND the message's real file attachments (up to 25 MB each; the person's explicit opt-in is the one exception to "external file content never enters the mesh unless the person saves it") | yes — opened and searched through the provider; a file over 25 MB, an inline image and an item or reference attachment are only ever read live |
| the VERDICT | yes — {user}/_Triage/{provider}-{messageId} |
— |
A verdict holds only what the inbox row needs and what the triage decided:
| field | meaning |
|---|---|
provider |
an open string: outlook, gmail, imessage, whatsapp |
messageId |
the provider's own IMMUTABLE id — how the verdict is joined to the live row |
from, subject, receivedAt |
the row (receivedAt is the row's time — InboxQueryLeg.TimeField) |
webLink |
the message in its provider's own client — offered as Open in … on the reading view, never where a click goes |
importance |
an open string: important or ignorable |
tags |
open strings, several per message (below) |
reason |
one line: why this verdict — shown on the row's tooltip |
model, triagedAt |
which model decided, and when |
replyObvious, replyReason |
whether the answer to a reply message is obvious enough for a prepared draft, and why (prepared replies) |
draftState, draftLink |
a mirror of the prepared-reply record for the row: ready and where to open the draft |
correctedBy, correctedAt |
the person who last corrected importance or tags (null while they are the model's) — a person's edit is never overwritten |
Importance and tags
Importance answers the one question that matters most: is this for me, or can I ignore it? Ignorable — spam, promotions, newsletters, automated notices nobody asked for — is out of sight by default, one switch away.
Tags are GitHub-style labels, OPEN strings (never an enum — a package or a person adds their own), several per message:
| tag | the message… |
|---|---|
act |
asks you to do something |
decide |
needs your decision or approval |
reply |
waits for your answer |
read |
is important to read, nothing to do |
working-on |
belongs to a thread you are active in |
waiting |
you replied; the other side owes the answer |
fyi |
is informative, nothing owed |
promo |
is commercial |
spam |
is unwanted |
| topic | the agent proposes topics — a client, a project, billing, travel |
The flow
new message (Graph change notification / poll) ──▶ triage job (the user's delegated credential)
──▶ the EU model: importance + tags + reason ──▶ verdict node {user}/_Triage/{provider}-{id}
──▶ the personal inbox: a leg over {viewer}/_Triage, filtered by importance and tags
- Reading uses the person's OWN delegated credential (
IEaGraphAuthfor Outlook — the Executive Assistant's; see ExecutiveAssistantCredentialReads). Nothing is read with an application credential, and the triage sees only what the person can. - Thread context: the triage reads the message and its conversation's earlier messages,
including the person's own replies — that is what tells
working-onandwaitingapart. - Corrections teach: a tag the person adds or removes is kept (
correctedBy), and the last corrections are given to the model as examples on the next run. - Idempotent: one verdict per message (its id is derived from the provider's message id); a redelivery is refused, never duplicated — the inbound lane's claim rule.
In the inbox
- The side menu: Important first, then each tag with its count, then the kinds (Mail, Conversations, Approvals, …). Ignorable is one entry at the bottom.
- Select many and act — shift-click a range, cmd/ctrl-click to toggle — and the command row offers the actions every selected message has in common: Archive, Delete, Mark read, Tag, and for approval requests, Approve and Reject. Actions call the provider (Graph's move, delete, mark read).
- Each row shows its provider's symbol; a provider's switch is three-way: only this, show, hide.
- Search goes to the provider's own search (Graph
$search), merged with the verdicts.
Built: live Outlook source
The person's Outlook Inbox is a source of the unified inbox (/{user}/Inbox), read live — nothing
is replicated. It ships in the MeshWeaver.Mail.MicrosoftGraph module (Inbox/):
| piece | what it does |
|---|---|
OutlookMailboxQueryProvider |
answers the reserved namespace {user}/_Mailbox/outlook (nothing is stored there) by calling Graph GET /me/mailFolders/inbox/messages — $top = the query's limit (at most 100), $orderby=receivedDateTime desc, and only id, subject, from, receivedDateTime, isRead, webLink, conversationId, bodyPreview. The LIST never reads a body; the reading view reads one message's body when it is opened (below). |
| search | the inbox's search box becomes Graph's own $search. Graph refuses $search together with $orderby, so a search asks without it and the rows are ordered newest first here. |
| one message → one node | {user}/_Mailbox/outlook/{id} (the id base64url-encoded), node type Mailbox/OutlookMessage, title = subject ((no subject) when empty), content receivedAt (UTC), from (display name, else address), fromAddress, isRead, webLink (the reading view's Open in Outlook), conversationId, preview. |
| the leg | source Outlook 📧, category Mail, band NeedsYou, timed by receivedAt, anchored on the viewer's own partition. Listed only for a viewer who CONNECTED a mailbox (below). |
| the account | every row carries account — the address of the mailbox it reached — which core's inbox shows in its Account column (owner, 2026-10-15: "we have many emails => show which mail account it is"). The leg reads the PRIMARY mailbox, so that is its address; a grant written before addresses were recorded has none, and the column stays empty rather than inventing one. The address is read beside the messages from the stored grants (IEaGraphAuth.GetMailboxes); a read that fails or takes over 5 s answers the rows without it (logged). |
| connected mailboxes | {user}/_Mailbox/outlook-accounts/{accountId}, node type Mailbox/OutlookAccount — one node per mailbox whose grant is not positively not connected, primary first; content accountId, address, primary, never a token. A sort: on name, path or id orders the nodes BEFORE the limit; any other sort is refused by name. Answered from the stored grants, never from Graph, and only to the owner. An empty answer means nothing is connected (also on a host with no Microsoft connection configured); a grant list that cannot be read is an error, never an empty list. |
| Open (the reading view) | a click on a row ALWAYS opens the message INSIDE the portal — core's inbox reading view, /{user}/Inbox/{item} (owner, 2026-10-16: "when clicking, it should always give me a memex view with the content, not redirect me"). OutlookInboxItemReader reads it LIVE when it is opened: Graph GET /me/messages/{id} with Prefer: outlook.body-content-type="text" (and the immutable-id preference), $select=id,subject,from,receivedDateTime,body,webLink. The view shows subject, sender, the account, the time in the viewer's zone and the body as plain text (an HTML answer is stripped); Open in Outlook (webLink) is a secondary link under it. Nothing is stored; only the owner is answered; a message Graph no longer has, or a mailbox not connected, is said. |
| Delete / Undo | OutlookInboxItemHandler: Delete is Graph's move to Deleted Items, Undo moves it back to the Inbox. Nothing is written to the mesh. Every call asks Graph for immutable ids, which survive a move, so the row's path still names the message after Delete; the id a move returns is remembered too and preferred by Undo. |
Only the owner is answered. The query provider and the handler compare the caller with the
{user} segment; anyone else — another user, the system identity, the anonymous visitor — gets
nothing, and Graph is not called. Every read and move runs under the person's own delegated
credential (IEaGraphAuth, the Executive Assistant's consent), inside the HTTP I/O pool.
Only connected sources are listed (owner, 2026-10-15: "i see a lot of stuff that's not even
configured ==> must get only configured sources"). The leg names its connection —
InboxQueryLeg.ConnectedWhen =
namespace:{viewer}/_Mailbox/outlook-accounts nodeType:Mailbox/OutlookAccount content.primary:true limit:1 select:path —
and core's inbox lists Outlook (switch, menu entry, read) only when that finds a node. Only the
PRIMARY mailbox counts, because the leg reads the primary mailbox alone: an added mailbox with a
disconnected primary would list a source whose every read fails as not connected. The grant
itself lives in the Auth partition, which an inbox leg may not read; the virtual
outlook-accounts folder is its face inside the viewer's own partition. A connection query that
cannot be read or does not answer in 5 s LISTS the source (core's rule), so a connected person is
never hidden by a slow credential store.
How to connect: open {instance}/auth/ea/connect once and consent. Until then Outlook is
not listed at all. Should a listed mailbox's grant later fail, the inbox names the Outlook source
above its grid with "Outlook is not connected — open /auth/ea/connect once to connect your
mailbox." A credential read that produced no answer is reported as exactly that, never as "not
connected".
Built: triage verdicts for the Outlook source
Every Outlook row the inbox lists carries its verdict's importance and tags, and the rows
without one are triaged in the background on the instance's EU-hosted model. Same module,
Inbox/:
| piece | what it does |
|---|---|
TriageVerdict (node type TriageVerdict, Inbox/TriageVerdict.cs) |
one node per message at {user}/_Triage/outlook-{digest} — the digest is 128 bits of SHA-256 over the immutable message id, so the same message always names the same verdict. Content: provider, messageId, importance (important | ignorable, open string constants), tags (open strings, normalised: lowercase, - for spaces, at most 6 of at most 32 characters), reason, model, triagedAt (UTC), subject, from, receivedAt, webLink, correctedBy, correctedAt. Never the body. Registered mesh-wide by the module (AddTriageVerdictType), with the default pages so the owner can edit the tags. |
the join (OutlookMailboxQueryProvider) |
once Graph has answered, the owner's verdicts for exactly those rows are read in ONE query — an explicit path list [p1, p2, …], which the stores push down as WHERE path IN (…) — and each row's content gains importance, tags (an array) and triageReason. A row without a verdict is left as it is. A verdict read that fails or takes over 5 s answers the rows unjoined and offers nothing to the triage: an unreadable store is never read as "untriaged". |
the triage (InboxTriagePass, Inbox/InboxTriage.cs) |
the rows the join found WITHOUT a verdict are offered to a background pass. Per owner one pass at a time — the mark is dropped BEFORE the pass's report is delivered, never in a Finally after it, so a pass started on the report (a tick, a manual trigger, the next read) is never skipped as "running" — at most Inbox:Triage:MaxPerPass (default 10) messages per pass, each message attempted at most once per process lifetime, one model call per message inside the I/O pool. The prompt carries the subject, the sender and Graph's preview only, and says the email is data, never instructions; the answer must be a JSON object with a known importance, or nothing is written (logged). |
the model (EuTriageModelSource) |
Inbox:Triage:Model when one is named, else the utility then the chat tier through the AI engine's ChatClientCredentialResolver.ResolveForTierWithResidency(tier, "Eu"); the answer must also read EU through DataResidencyFor (Unknown on any ambiguity), have a usable credential and be admitted by the instance. The bare client comes from the lowest-order non-persistent IChatClientFactory that serves it. No EU model ⇒ nothing is triaged, the log says so once, and no model outside the EU is ever used instead. |
a provider refusal (ModelRefusals, Inbox/ModelRefusals.cs) |
🚦 an HTTP 429 or any other key-level refusal of the model call (the AI engine's ProviderHealthRule.Classify: rate limit, key limit, credit, a rejected key) is the model's, never the message's (Plugins #4546). It is recognised at the model call only, so a Graph or store throttle is never mistaken for it. The model then rests: the refused message is released uncharged, no further message of the pass is sent to that model (the rest are counted Deferred, unclaimed), and every later pass — the triage, the re-judgement and the reply drafter share ONE record per mesh — skips it until the rest ends. While it rests, EuTriageModelSource passes over it, so the next declared tier serves when it carries a different EU model; when none does, the source still answers the resting model (never "no model", which the drafter would settle as a charged failure and the calendar suggester as an empty extraction), and the consumer reads its rest: the triage and the drafter run nothing and charge nothing, and the calendar suggester's call fails the item for a later pass, as any model failure does. The rest follows the agent pool's declared back-off for a live 429 (LanePolicy.BackoffOf: 30 s, doubling per refusal since the model last answered, at most 15 min); it is a not-before stamp the next pass reads — no timer, no sleep, no retry — and an answer clears it. Per process: a restart forgets it. Before this, every message of a pass was sent to the refused model and charged its once-per-process attempt, so the backlog was spent on the provider's weather. Any OTHER failure is still the message's. |
the write (MeshTriageVerdictStore) |
create-only, as System on the owner's behalf (the pass runs after the request has gone), scoped to the owner's _Triage. An existing verdict refuses the create and is left exactly as it is — so a verdict the owner corrected is never overwritten, because the model never overwrites anything. |
A filter on the verdict (content.importance:important, content.tags:reply, a negation) is
decided on the ENRICHED row: the owner's newest 1000 verdicts are read first and each Graph candidate
is matched with its verdict stamped in, so the branch's limit still counts matching rows while Graph
is paged, and an untriaged row matches -content.importance:ignorable. (A verdict older than those
1000 is still joined onto its row, but not seen by such a filter.) A namespace alternation over
several mailboxes is authorized per owner.
Why this cannot feed on its own writes. The pass is triggered by a READ of the live mailbox,
never by a query over _Triage, so a verdict it writes cannot trigger it again. Should a pass still
find a verdict this process wrote itself (its claim set is bounded and starts over), it logs an
Error naming the path.
Why Mail may depend on the AI engine. It already does (the Executive Assistant's tools and the
connection providers), and the package declares AI in its requires; the triage adds no edge.
Configuration (Inbox:Triage): Enabled (default true), MaxPerPass (default 10), Model
(an explicit selection, still used only when it is marked EU).
Not yet: the person's edit does not stamp correctedBy by itself — the generic editor writes
the tags; the model already leaves every existing verdict alone, so the field is a statement for
readers until the inbox's own tag action sets it. A verdict written during a render shows on the
inbox's NEXT read. Thread context (earlier messages of the conversation) and corrections given to
the model as examples are not built.
Still open from the design above: the tag side menu and hiding ignorable mail (core's inbox, which
reads importance and tags from the row content), mark read, thread context, and the other
providers.
Built: the opt-in copy
"can you also optionally offer that data is replicated?" — the maintainer, 2026-10-16. Decided the same day: when a source's copy is switched on, memex keeps full messages (subject, sender, account, time and the whole body), backfilling the last 90 days and then keeping up. Live stays the default. Amended 2026-10-24 (maintainer): the copy also stores each message's real FILE attachments, up to 25 MB each — see Attachments in the copy below.
Built for Outlook (every connected mailbox) and Gmail. 🚨 Per account, per installation, opt-in: the
switch is one per connected account, on the installation the person is using — a copy exists only where it was
switched on, connecting an account never starts one, and a Google or Microsoft grant held on a mother
instance for a child implies no copy there. The Inbox copies tab lists every connected account with its own
switch (all off); IMailCopySwitch (Offer, Enable, Disable, IsOn) is the call onboarding makes to
ask "Keep a copy on this installation?". The provider-neutral half (stored shapes, copy store, generic engine,
attachment rules, the switch) lives in this package's module, namespace MeshWeaver.Mail.MailCopy — see The Opt-In Mail Copy; the Google package requires Mail for it.
🚨 The copies live in a partition of their own per person, {owner}_MailCopy, never in the home partition
(owner, 2026-10-25: "i don't feel good downloading all this stuff to user space. let's see that we create a
dedicated partition just for the inbox for every user"). It is made as System on switch-on, and its one grant is
the person's own Viewer grant: a grant on the home partition does not reach the mail. A copy made before that
date sat at {owner}/_MailCopy/…; its next sync moves it — source with its cursor, copies, attachment files,
then the legacy nodes and files are deleted — so nothing is downloaded again (The Opt-In Mail Copy).
The default account of a provider keeps the key {provider} ({owner}_MailCopy/{provider}); every other
connected account is its own copy at {owner}_MailCopy/{provider}.{accountId}. Gmail's
copy follows Gmail's history id (a history id Gmail no longer knows rebuilds the copy as a new generation).
Not yet: the inbox reads a copy back only for the default Outlook account (the other mailboxes and Gmail are
synced and searchable as nodes, but their inbox rows are still read live), and Gmail supports one Google account
per person today. Teams chats and the Mac sources (iMessage, WhatsApp) are not copied — see Not built below.
| piece | what it does |
|---|---|
| the switch | Settings → Inbox copies (/{user}/Settings/InboxCopies, a person-app tab, MailCopySettings): a TEMPLATE that loads nothing on the hub — a reactive mesh search lists each copy that is on through its source node's own Status area (the node's fields read-only and bound: when it started, the window, whether the backfill is done, the last sync and the last error), a note that with nothing listed Outlook is read live, and Keep a copy of Outlook in memex / Stop and delete the copies. Stopping asks first and says the copies will be deleted; the mailbox is never touched. Only the space's own person can press either — the click checks it again (KeepACopy, DeleteTheCopies), and the confirmation reads the clicker anew, so a dialog the owner opened and someone else confirmed deletes nothing. |
| the source node | {user}_MailCopy/outlook, node type MailCopySource: generation, enabledAt, windowStart (= enabledAt − 90 days), cursor (Graph's next or delta link), backfilled, lastSyncAt, lastError, leaseHolder, leaseUntil, mailboxId (the Microsoft account the copy was made from — the delegated token's own tid/oid claims, or for an opaque personal-account token the Inbox folder's id; never /me, which needs User.Read, a scope the consent does not ask for). Its absence IS "off". It is created once when the person switches the copy on, and afterwards changed only by a generation-checked commit at its owning hub (IMailCopyStore.Commit — the change is applied to the node as it is THERE, and only while it carries the generation the sync read): a sync can never re-create a source the person switched off, nor overwrite one switched on again. |
| the generation | a fresh id per switch-on and per rebuild. Every copy carries it and its id includes it, so a late write of an earlier generation lands BESIDE the current copy, never on it, and that generation's leftovers are removed by generation (RemoveGeneration) without touching the current one. |
| one copy | {user}_MailCopy/outlook/{digest}, node type MailMessageCopy — the digest is 128 bits of SHA-256 over (generation, immutable message id), so a re-sync overwrites (create-or-replace) and never duplicates. Content: messageId, generation, subject, from, fromAddress, receivedAt (UTC), receivedTicks (Unix ms), orderKey (receivedTicks × 1000 + three digits of a hash of the id — what the store orders and pages by: a number, since a stored instant's text form does not compare in order; unique within a millisecond, so a page boundary advances past messages received together; below 2^53, so it compares exactly as a double), isRead, webLink, conversationId, preview, body (plain text — an HTML body is stripped before it is stored), copiedAt (when this copy was written — a late Delete compares against it). |
the sync (OutlookMailCopy) |
Graph's delta over the Inbox folder (/me/mailFolders/inbox/messages/delta), unfiltered — Graph caps a delta carrying a receivedDateTime filter at 5,000 messages and still answers a delta link, so a filtered backfill of a busy Inbox would end "done" with part of the window missing; the 90-day window is applied to what comes back. Body as text, immutable ids; up to 20 pages of 50 per step and 10 steps per sync. Within a step each message's LAST event wins (Graph may name one message on several pages — changed then removed, changed twice), so the step writes every id once and its concurrent writes cannot apply an earlier event after a later one. A removal (moved out of the Inbox, deleted) replaces the copy by a tombstone dated now (no subject, sender or body; never listed — left out before the limit — nor opened), and an inbox Delete leaves one dated when it was asked: a write older than the removal (an Undo whose Graph read lost the race) is refused against it, a newer one replaces it. The cursor is committed after every step. A failed step records lastError and keeps its cursor, so the next sync repeats it. When Graph refuses the cursor (HTTP 410, syncStateNotFound, syncStateInvalid, resyncRequired) the copy is rebuilt: a new generation, no cursor, backfilled false — the inbox reads Outlook live until the fresh backfill is done — and the old generation's copies are removed, so nothing deleted while the cursor was dead survives. One sync per copy at a time, across replicas: a sync first takes the copy's LEASE (leaseHolder/leaseUntil, 10 minutes, through the generation-checked commit); another sync that finds it held stands down without reading Graph. The lease is renewed before every step's writes and with every cursor commit, and released when the sync ends — a failed one too; a crashed holder's lease lapses. A step whose renewal is refused writes nothing. A step's writes never short-circuit: a failed write is counted, the rest still run, and the cursor is then NOT advanced (the step repeats) — so the commit, or the clean-up after a switch-off, always covers the whole step. At most one sync every 2 minutes unless the person asked; a sync's first lookup is bounded (5 s) and a whole sync at most 30 minutes, so a stalled one always frees the person for the next. 🚨 The delta steps run in a pool of their OWN (2 at a time), so backfills in flight never take the slots the live inbox, a message open or Delete/Undo need. |
| the account | A copy is bound to the account it was made from (mailboxId, recorded by the first sync). It is served — in the list and in the reading view — only while the primary mailbox is connected to THAT account (read from the token or the Inbox folder, remembered per CONNECTION — keyed by the delegated token, so reconnecting another account reads it again; a credential read that gives no answer is an error, so the inbox reads live and a sync releases its lease). When the person connects another account the inbox reads Outlook live at once, and the next sync rebuilds the copy from the new account (a new generation; the old account's copies are removed). Every sync also sweeps leftovers of earlier generations — a rebuild whose clean-up was interrupted — not only when the copy is off. |
| the generation id | 🚨 also owner-writable content, and spliced into store queries — so only a well-formed id (1–64 letters, digits, -) is ever used in one; anything else is refused before the query, and a sync that finds one rebuilds the copy under a fresh id. Every privileged delete re-checks each node first: directly under the copy's own source path, of the copy type, of exactly that generation. |
| the cursor | 🚨 A stored cursor is node content in the person's own partition, so it is never trusted: a cursor (or a next link Graph returned) that is not absolute HTTPS on graph.microsoft.com, default port, without user info, is refused BEFORE any request and the copy is rebuilt from a fresh delta. The delegated token is handed only to Graph URLs — any other request goes without it. A sync that fails before its steps (the account lookup, the sweep) records lastError too, without moving the cursor. |
| one connection | An Undo reads its message through ONE connection whose account must BE the copy's (after a reconnect, before the next sync rebuilds, it copies nothing), and its clean-up after a switch-off is a tombstone at the Undo's own time — it replaces only what the Undo wrote. A sync makes ONE credential read and binds to it: the account check AND every delta step use that token, so a reconnect to another account mid-sync can never land that account's mail under the first one's identity. |
| what starts a sync | switching the copy on; every read of the inbox's Outlook leg by its owner — with the copy off too, where the sync only sweeps leftovers (so a late write behind a switch-off is swept even after a restart); and MailCopyKeeper, every 10 minutes, for every person this process has synced. 🚨 Never a change under _MailCopy — the sync writes only there, so it is never fed its own writes. |
| the inbox | The Outlook leg answers from the copy once the backfill is done — before that, and whenever the copy is off or the decision (the source read AND the account check, one 5 s budget for both) cannot be made in time, it reads Graph live. 🚨 The copy holds only its window, so it answers ALONE only when it fills the page newest first (those are then exactly the newest rows); an oldest-first list is read live; and a page the copy cannot fill — a search, an old unread message — is the copy's rows unioned with the live ones (each message once, the copy's version first), so nothing outside the window disappears. The copy is turned back into the same Graph messages the live read returns, so the rows, filters, triage join and account stamp are the live ones, and Outlook is listed once either way. The copy is paged in receive order (200 at a time, at most 100 pages) until enough copies MATCH the search and the row filters — never cut at a fixed count first, which would answer a match just past the cut as absent. A search needs every word in the subject, sender, preview or body. 🚨 The read from the copy is bounded (10 s) and falls back to Graph live on a failure or a stall, so a copy can never fail or hang an inbox whose Outlook is reachable; and it is re-checked after the pages: if a sync rebuilt the copy or the person switched it off while it was read, the answer is refused for the live one rather than given empty or partial. |
| the reading view | shows the stored body when the copy holds the message; otherwise — or when the copy cannot be read within 5 s — reads Graph live, as before. |
| Delete / Undo | still move the message in Outlook; Delete then removes its copy — unless the copy reflects something AFTER the Delete was asked for (copiedAt: a sync since, or an Undo ASKED later — an Undo's copy carries the time it was asked, so the order of the person's moves decides, never how long a write took; and every move is stamped BEFORE its Graph request is sent, so a late response never makes an earlier move look newer) — and Undo re-reads it and writes the copy back only when it was received inside the window, and only over an OLDER copy (WriteIfNewer, decided at the owning hub — a sync's newer state is never replaced by an Undo's late Graph read), as the sync would, then checks again that the copy is still on in the same generation — if the person switched it off (or on anew) while Graph was being read, or the re-check cannot be read, the copy Undo wrote is removed again. The follow-ups of one message apply in the order they were asked for (in a process); a stalled one holds the next back at most 10 s, after which the asked-time stamps alone keep the order. The follow-up runs detached and the outcome waits for it at most 5 s, so a slow copy never holds back a move Outlook already made. The next delta reconciles either way. |
| switching off | deletes {user}_MailCopy/outlook (a copy still at the legacy place is moved first) and every copy under it, of every generation, in one call. A sync running at that moment finds its commit refused, removes the copies it wrote meanwhile by their generation, and never switches the copy back on — and if the person switched it on again in between, the new copy is left alone. A copy a late write left behind anyway is swept by the next sync of that person (which, for a copy that is off, only looks for leftovers — Sweep). 🚨 The sweep LISTS the copies first and reads the source AFTER, removing only copies whose generation is not the source's as it then stands — so a copy switched on (and backfilled) by another replica meanwhile is never touched. |
No history. Both node types are registered with keepsHistory: false, so no version snapshot of a copied body is kept — on Postgres the trigger-written snapshots are purged after each write, the platform's own behaviour for such a type (core NoHistoryTypePgTests). "Stop and delete the copies" therefore leaves nothing of the mail behind, history included. Every write of a sync step — changes and tombstones — goes through WriteIfNewer, dated when Graph was ACTUALLY asked — taken inside the request's own pool slot, never when the step was queued — so a Delete or Undo asked after it is never overwritten, and an outside change Graph reports after the person's move is never refused as stale. 🚨 And because ordering by clocks is only ever a guess (a retried request, a slow response), an event that LOSES to a local action is not simply dropped: before the cursor moves past it the message is re-read from the Inbox as it is NOW (/me/mailFolders/inbox/messages/{id}) and the copy settles on that — copied when it is there, tombstoned when it is not — as a compare-and-set on the stored revision (the node's own version, which moves on EVERY accepted write — copiedAt orders actions only) read BEFORE Graph was asked: a Delete or Undo that completed while the answer was in flight makes it a conflict, and the reconcile asks again (up to 3 times, then the step repeats).
Access. The copies live in the person's own copy partition, {user}_MailCopy, and are read as that
person through its one grant, their Viewer grant; the background writes run as System on their behalf,
scoped to that partition. Nothing grants anyone else a read — a global administrator included, and a grant
on the person's home partition stops at the home partition. Proven by
MeshWeaver.Mail.MicrosoftGraph.Test — OutlookMailCopyTests (scripted Graph), MailCopyMeshTest
(a real mesh) and MailCopyAccessMeshTest (a mesh with row-level security and no blanket grant: another
person reads nothing, the owner's identical query finds the copy).
Not built. Teams chats, Gmail, iMessage and WhatsApp copies. Gmail lives in its own module
(MeshWeaver.Mail.Gmail), which shares no assembly with this one, so its copy needs either a shared
home for the two node types or its own; the Mac sources run in the LocalMesh app on the Mac, where the
chat databases already are. After a restart, a copy catches up the next time its owner opens the inbox
(the keeper only revisits people this process has seen); nothing is missed, since Graph's delta is
cumulative. The inbox's own settings menu does not yet link to the tab (core's inbox has no extension
point for it); the tab is in the person's settings app.
Attachments in the copy
The copy is the person's explicit opt-in, so for it — and only it — the rule that external file content
never enters the mesh unless the person saves it is reversed (GovernedMail rule 7 names the exception).
- What is stored: a message's real FILE attachments, up to 25 MB each (the size Graph lists is
checked before anything is downloaded; the downloaded bytes are checked again). Skipped, with the
reason recorded:
inline(an image of the body),itemandreference(an attached message or event, a cloud link),too-large(the file stays with the provider and is read live on demand), andfetch-failed(a download or store that failed — one attachment's failure never fails the sync). - Where: the content collection of the copy node — every node has an
attachmentscollection (a nested node has nocontentcollection of its own, and an inherited one is refused). File{copyPath}/attachments/{attachmentIdDigest}/{cleanedFileName};copyPathis{owner}_MailCopy/{provider}/{digest}. - What the node records:
MailMessageCopy.Attachments(null= not decided yet; a list = decided, an empty one for a message without attachments), each entry aMailAttachmentCopy:AttachmentId,Name,ContentType,Size,IsInline,Kind(file/item/reference),StoredPath(the full path above, null when skipped) andSkippedReason. The shape is provider-neutral (IMailAttachmentSourcelists and fetches,IMailAttachmentStoresaves and deletes); the Executive Assistant readsStoredPaththrough the content collection. - When: the copy node is written first (the files need the node), then the attachments are fetched and stored and the decision recorded with a compare-and-set on the node's revision. An isRead flip or any other delta event carries the stored list over and never fetches again; a rebuilt copy (a new generation) fetches afresh. A listing that fails leaves the message undecided until it changes again.
- Indexing: each stored file raises the content-upload notification, so the content index chunks it
and writes its
Documentnode — at{copyPath}/attachments/_Documents/…, i.e. under the copy, as are its chunks (keyed by the collection path{copyPath}/attachments). Anything that excludes mail by the_MailCopypattern therefore excludes the attachments' documents and chunks too. - Deleting: nothing in the platform deletes a node's content-collection files when the node is
deleted, so the copy does it explicitly, files first: on switching the copy off, on removing a copy or
a generation, and when a message is tombstoned. 🚨 It empties the copy's whole
attachmentscollection by ENUMERATING it, never from the recorded list — a save interrupted before its list was recorded, and a list a tombstone replaced, leave files no list names (HasAttachmentstells a copy whose list is not recorded yet that it may hold files). A failed delete is never swallowed: the node (the handle on the files) stays and the step or switch-off repeats; a tombstone is written only after its files are gone. Each file delete raises the delete notification, which removes the file's chunks andDocumentnode from the index. Known residual: a Delete and an Undo of the same message racing on two replicas inside the same second can leave an Undo's copy pointing at deleted files, and an indexing run still in flight for a file when its delete is announced used to write that file's chunks after the removal (the content indexer runs uploads and removals independently). The indexing activity now looks the file up again once its chunks are committed and removes the index again when the file is gone; theReindexAllwalk has no such re-check and a proper fix belongs insideContentIndexingService. Filed into bug triage atrbuergi/Feedback/index-chunks-survive-delete-during-indexingon the control instance. - Existing copies: a delta cursor keeps the field projection it was started with, so a copy made
before attachments (
MailCopySource.Projection0) is rebuilt once — a new generation, a fresh cursor, the old generation's copies removed — before it keeps attachments. The inbox reads live meanwhile.
Built: Emails is a search scope — off in the top search unless asked
"emails in top search are sensitive. should have an option to have them disabled by default and only when explicitly checking emails as search scope we will do it" — the maintainer, 2026-10-24.
Where mail could appear in a search before. The portal's top bar and /search page run
partitions:all free-text queries over the mesh. The live mailbox providers ({user}/_Mailbox/outlook,
…/gmail, {user}/_Chats/teams) answer only a query whose namespace is exactly theirs, so a mesh-wide
search never reaches them. The opt-in copies are ordinary nodes at {user}_MailCopy/{provider}/{digest} — a top-level partition that a partitions:all search reaches directly —
(and whatever is stored below a copy, attachments included); the bar's suggestion dropdown (context:search
hides underscore paths) never listed them, but the search page did — it states no context — and so did
the page's In documents section for any chunk under the searched namespace.
Now: mail is a named scope, off unless asked for.
| surface | default | how mail is asked for |
|---|---|---|
| the top bar's dropdown | excluded | the Emails checkbox at the top of the dropdown, or source:mail in the typed term |
the search page (/search) |
excluded | the Emails checkbox above the results, or source:mail in the box |
| the page's In documents chunks | dropped when the scope is off | the same checkbox / token |
| a person's own default | includeEmailsInTopSearch on {user}/_Mail/Preferences (the Mail tab of their settings), false |
switching it on makes the checkbox start ticked; -source:mail still refuses it for one search |
- Enforced where the rows are produced. The exclusion is
-namespace:filters (*_MailCopyfor the copy partitions,*/_MailCopyfor a copy not yet moved out of a home partition,*/_Mailbox— each the namespace and everything below it, so a copy's children go too) on every line of the query the portal sends, evaluated by every query provider; nothing is fetched and then hidden. Ticking Emails removes them. On the bar the included scope adds one leg per mail namespace anchored to the person's own partitions ({user}_MailCopy,{user}/_MailCopy,{user}/_Mailbox—SearchScopes.OwnerNamespaces), never a mesh-wide fan-out. - The scope is declared by the module (
ISearchScopeContributioninMeshWeaver.Blazor.Search; this module's isMailSearchScope), so a portal without the mail module shows no Emails checkbox and excludes nothing. The grammar never seessource:mail: the surfaces strip it (SearchScopes.Extract), andMeshSearchViewlifts the page's exclusion when the typed text asks.source:accessedandsource:activityare untouched. - Anonymous viewers and an unreadable preference leave the scope OFF.
- Tests:
SearchScopesTest(the filter, the qualifier, the bar's queries, the preference flipping the default),MailSearchScopeTests, andMailSearchScopeMeshTest(stored copies and a node below a copy, default / explicit / control).
Not covered yet. The MCP search tool and the AI agents' default searches build their own queries and
do not go through this seam: a partitions:all search from an agent can still find the owner's copies (only
the owner can read them). The Emails scope also searches the copies only; to search a mailbox that has no
copy, use the inbox's own search, which reads the provider live. Gmail-only deployments (no Outlook module)
have no Emails scope entry — Gmail keeps no copy and its live namespace is never reached by a mesh-wide
search, so there is nothing to exclude.
Built: the person's mail preferences — the three questions
The setup wizard's mail panel (MailOnboardingPanel, area OnboardingMail
on the person's own user node; also the Mail tab of their settings app, /{user}/Settings/Mail)
asks the owner's three questions (2026-10-17) and stores the answers on {user}/_Mail/Preferences
(MailPreferences, a form bound straight to the node):
| question | field | default | what it drives |
|---|---|---|---|
| should mails be pre-categorized? | sorting |
on | the inbox offers a person's untriaged rows to the pass only while this is on (OutlookMailboxQueryProvider, read live on every offer; an unreadable preference reads as the default) |
| should it be synced, and for how long? | the Inbox copies switch (unchanged, one switch, with its confirmation) + copyWindow (last month / three months / year) |
off · three months | Keep a copy starts the backfill at the chosen window (OutlookMailCopy.Enable(owner, windowDays)) |
| should replies be prepared even before reading? - the reply can be edited before sending | replies (Prepare replies when the answer is obvious / Never) |
when obvious | the reply pre-drafter |
| should emails be searched by the top search? (2026-10-24) | includeEmailsInTopSearch |
off | the default of the Emails search scope (below); read live by the search bar and the search page |
Built: prepared replies - an obvious answer is drafted for you
"if i need to answer, have an agent prepare the answer for me" - the owner, 2026-10-25; narrowed the same day: "only when ingest thinks the answer is obvious"; and "as email ... or whatever the comms channel is".
When the triage puts a message in the Reply bucket AND judges that the answer is obvious, the
instance prepares the reply for the person to review. Nothing is ever sent: the person presses
Send, exactly as Email:AgentSend = DraftOnly promises.
What "obvious" means, and who decides
The triage model answers one more question next to the tags, in the same call: replyObvious (true or
false) and replyReason (one line). Obvious is a confirmation, a scheduling yes or no, a plain
factual answer from what the message itself says, a simple acknowledgement or thanks. Anything that
needs the person's judgement, negotiation, a commitment, money, a legal view or a sensitive touch
is false - "when in doubt, false" is in the prompt - and gets no draft: it stays in the Reply
bucket for the person to write. Only a JSON true on a message tagged reply counts. The flag is
stored on the verdict (ReplyObvious, ReplyReason) beside the tags, so a person can see why.
A second check sits at the draft: the drafting model reads the WHOLE message (the triage saw only the
preview) and may hand it back with DECLINE: <why> - then no draft is written and the record says so.
The flow
verdict {reply, replyObvious} ──▶ ReplyOfferingVerdictStore ──▶ ReplyDrafter (per owner, bounded)
(created by the triage, or read for the inbox) │ person's preference (Replies = WhenObvious)
│ EU model available?
▼
claim {user}/_Replies/{provider}-{digest} (create-only record, lease)
▼
IReplyPreparer(provider).Read ─▶ EU model composes ─▶ FindExisting ─▶ Create | Amend
▼
record: ready + draft link ─▶ mirrored on the verdict ─▶ the inbox row and reading view
- Trigger.
ReplyOfferingVerdictStoredecoratesITriageVerdictStore: a verdict the triage has just created is offered at once, and a verdict read for the inbox is offered too (the catch-up for a verdict written before the drafter ran, or before the person switched replies on). No query provider changed. The durable record makes an offer that was already served a no-op. - One prepared reply per item. The record
PreparedReplyat{user}/_Replies/{provider}-{digest}(the digest is the channel's immutable item id, so it survives a re-triage, a rebuilt mail copy - a new generation keeps the provider's ids - a second replica and a restart) is claimed create-only before anything is drafted. States:pending(with a lease; a crashed attempt is taken over after it lapses),ready,taken(the person sent it or has a draft of their own),not-connected,declined,failed. A failed or not-connected attempt is retried after 15 minutes, and a lapsed claim is taken over, at most five attempts in all. 🚦 A provider refusal of the model (HTTP 429, a key limit, credit - seeModelRefusalsabove) is not one of them (Plugins #4546): the model rests, the record is settledfailedwith its attempt given back, and the pass's later items find the model resting and are settled the same way without a call - so a provider's rate limit can no longer use up a mail's five attempts. A takeover is atomic at the owning node (the decision is re-made on the record as it is there, so two replicas cannot both win), and the find-then-create of a conversation is serialised across replicas by a durable per-conversation lease - aReplyLockat{user}/_ReplyLocks/{provider}-{digest}, its own type and namespace, so a lock is never listed among the prepared replies (before 2026-10-26 it was written as aPreparedReplynamed "conversation lock" into_Replies; those old nodes are bookkeeping nothing reads and may be deleted). The drafter throttles per process and runs one pass per owner at a time; an offer that arrives during a pass is queued (bounded) and served when it ends. Each conversation lease carries its holder's token and is released only by that holder. A draft whose ownership the conversation index cannot confirm is deferred (retried, bounded), never settled as the person's, and a settled record whose verdict mirror was lost is repaired by the next catch-up. Known limits: a crash (or a failed save followed by a restart) between the mailbox write and the record write loses the draft's identity - the draft then sits in Drafts unclaimed and is never amended or duplicated (ownership unconfirmed, deferred), but its row shows nothing; and the atomic takeover is as atomic asGetMeshNodeStream().Updateis across silos; a true owner-handled conditional claim would be a core operation (filed to bug triage), and its worst case here is two drafts in one conversation within the same seconds. - Every needs-a-reply mail gets a proposal (2026-10-26). A mail tagged
replywhose answer is obvious gets a ready draft (Kind = draft); every other one gets a suggestion (Kind = suggestion): still a draft in Outlook Drafts, but its first line is a banner ([SUGGESTION - not final ...]), it proposes the answer the conversation best supports and marks everything only the person knows or decides as[CHECK: ...], and the record's note and the reading view say it is a suggestion. The inbox row carries thesuggestion-readytag (instead ofdraft-ready) and the draft link; its tooltip says "Suggested reply waiting in your Drafts". Same claim, same one-per-conversation lock, same amend-never-re-draft path, same exclusions below - only the prompt and the banner differ. The setting keeps its stored valueWhenObvious(a serialised name) but now reads "Prepare replies for mail that needs my answer". Teams chats get suggestions too (kept on the record, nothing posted - see Teams suggestions below). - Only for mail addressed TO the person, only in their voice, never without a recipient. A reply is prepared
only when one of the person's own addresses is in the message's
Toand the message is from someone else. Mail they are only copied on (Cc) expects its answer from the addressee, so it gets no reply - neither a draft nor a suggestion; the same holds for mail that reached them through Bcc, a list or a forward, for their own messages, and for a message with no sender. The check is deterministic and runs before the model (ReplySource.NotAddressedReason, set byOutlookReplyPreparerfromtoRecipients/ccRecipients); the record saysdeclinedand why. The person's own addresses are the grants' recorded addresses plus theupn/preferred_username/emailclaims of their delegated token (no/mecall - the consent has noUser.Read); when none is known nothing is prepared (a failure, retried, bounded). The prompt names the person (RECIPIENT) and forbids writing as the sender or any other recipient, and a deterministic check over the model's text (ReplyVoice) refuses a reply whose sign-off names another participant - a sign-off made only of the person's own name words is theirs (a surname shared with another participant does not exempt that participant's full name), and a name the line ends on with!/?is someone being addressed ("Thanks, Max!"), not a signature. The draft's recipients are named explicitly oncreateReplythe way Outlook's own Reply routes it - the message'sReply-Towhen it names one (never the person's own address; a Reply-To naming only the person is declined), else the sender - and a create with no recipient is refused before any Graph call. A grant list or token that cannot be READ fails the attempt (retried) rather than shrinking the person's address set: a missing alias would otherwise settle "not addressed to you" for good. For Teams, the participants are the chat's MEMBERSHIP (/chats/{id}/members), not only who wrote lately. Why: a CC'd person once found a reply in their own Drafts written in the voice of the mail's To-addressee and signed with that name (control triage itemHosting/Triage/feedback/memex-systemorph-com-feedback-submissions-fb-3c7c352b77cd2c90b1971a1392bbdefd) - the re-judgement below had tagged the two-day-old mailreply, and nothing checked whom it was addressed to. - Never a reply to automated mail. An obvious ACTION (a link to renew an expired token) is not an obvious
REPLY: nobody writes back to a notification. Two deterministic guards run outside the model and over it
(
AutomatedMail): the sender is a machine (noreply,no-reply,donotreply,mailer-daemon,notifications,reply+<token>, ...), or the message headers say so (Auto-Submittedother thanno,Precedence: bulk|list|junk,List-Unsubscribe,List-Id- the Outlook preparer selectsinternetMessageHeaderson the one message it already reads). The triage clearsreplyObviousfor such a sender whatever the model said; the drafter declines a message the headers mark automated, before any model call, and the record saysdeclined. The triage prompt also definesreplyObviousas "a person expects a written answer and its content is obvious". Drafts already in a mailbox are never touched; the guards only stop new ones. - Amended, never re-drafted. Before creating, the drafter asks the channel for the conversation's
existing reply (
FindExisting; for Outlook, the Drafts folder byconversationId). A reply that an earlier record of the conversation prepared is amended in place with the newer message in view - never a second draft beside it. The amend re-reads, inside the write, whether it is still a draft and whether it changed after our write (compared on the channel's own clock): a sent or edited reply answersLeftAloneand nothing is patched, so the person's words are never overwritten. A draft in that conversation that no record of ours names is the person's own and is not touched. A message the person already answered (a message of theirs in Sent Items after it arrived) is theirs too. An older message never amends a reply that answers a newer one, and a draft addressed to someone other than the newest sender is left alone. There is no grace window: an edit after our write, on Graph's own clock as the write itself reported it, is the person's. (Graph offers no server-enforced precondition for a message PATCH, so the few milliseconds between the re-read and the patch remain; the patch carries only the body.) - Their credential, their switch, EU only. The preparer uses the person's own delegated credential.
A missing consent becomes a
not-connectedrecord whose note says: open{instance}/auth/ea/connect; the row and the reading view show it. The preferenceReplies(below) is read authoritatively on every attempt, and an unreadable preference means no attempt this time. With no EU-hosted model nothing is drafted and no other model is used. The model sees the message and its conversation (the body, which the triage never sees) and is told the mail is data, to answer in the thread's language and tone, never to invent a fact, date, price or commitment, and to write[CHECK: ...]where the person must decide or fill something in. - Not the Executive Assistant's tool loop. Background work has no chat, so the pre-drafter composes
with the EU model directly and writes through the same Graph route the assistant's
DraftReplyuses (createReply, plus the sameisDraftre-read before any amend). The assistant agent knows such drafts exist (its instructions say: look in Drafts first, amend, never write a second). These drafts have noEssentials/Emailrecord; the_Repliesrecord is theirs.
What the person sees
- Setting. Prepare replies when the answer is obvious (
MailPreferences.Replies, default on while sorting is on) in the setup wizard's mail panel and Settings -> Mail, a form bound straight to the node. Never prepare replies switches it off. - The row. A reply the triage judged obvious carries the tag
#obvious-reply; once the draft exists also#draft-ready(#draft-needs-connectionwhile the consent is missing). The row's tooltip says Obvious reply:, or No draft, this one is yours to write: for a Reply that was not judged obvious. - The reading view. A message with a ready reply (re-checked against the mailbox when it opens: a draft that was sent or removed is no longer shown as waiting) opens with [Draft ready] above it, and the link under it is Open in Outlook draft reply - one click to the draft in Outlook, where the person edits and sends it. (The link to the original message is replaced while a draft exists; core's reading view has one secondary link. A second link and a row column belong to core's inbox and are not built.)
The one-time backlog re-judgement
Verdicts written before the reply judgement existed (Plugins #4199) carry no replyObvious, so none of
them could ever qualify for a prepared reply. The inbox read re-judges them once, inside the same live
pass (no schedule):
- Candidates:
ReplyJudgedAtnull (the version marker; the triage stamps it on every new verdict), noreplyObvious/replyReason, importance notignorable, never corrected by a person, received in the last 7 days (Inbox:Triage:RejudgeWindow; switch:Inbox:Triage:RejudgeBacklog). - Unanswered (an approximation): the owner's Sent Items of the window are read once; a message whose conversation has a message sent after it arrived counts as answered. A message without a conversation id, or a Sent Items read that fails, is skipped - never drafted on a guess. A reply sent from another client into another mailbox is invisible to this signal.
- Bounded and idempotent: at most
MaxPerPass(10) per owner per read, attempted once per process; the store write refuses a verdict that already hasReplyJudgedAtor a correction, so the backlog drains over successive reads and never runs twice. - A verdict node written more than 5 minutes after its triage is treated as edited by the owner (the
generic editor does not stamp
CorrectedBy) and is never re-judged. - Only
tags,replyObvious,replyReasonandReplyJudgedAtchange; importance, reason and model stay. A written re-judgement is offered to the reply pre-drafter through the same decorator.
The seam for other channels
The drafter knows no channel. Routing is by the verdict's Provider string:
| to plug in | what the channel brings |
|---|---|
| a channel that is triaged (Teams chats, Gmail, WhatsApp, iMessage) | a triage path that writes TriageVerdicts with its own Provider through ITriageVerdictStore (so they pass the offering decorator), and the same replyObvious judgement |
| the preparer | an IReplyPreparer (MeshWeaver.AI.Replies) registered in DI with Provider equal to that string: Read the item and its conversation live, FindExisting, Create, Amend, plus the ConnectHint sentence |
The contract has no send - by design - and states the rules every preparer keeps: one prepared reply
per item and per conversation, amend instead of re-drafting, never touch what the person sent or edited,
the person's own credential only. A channel with no native draft (Teams chats) keeps the prepared text
on the chat item instead of in a Drafts folder: Create stores it on the item and returns a ReplyDraftRef
whose Link opens the chat in the portal, Amend rewrites it under the same LeftAlone rules (compare
the item's own edit time), and nothing is ever posted. Outlook's preparer (OutlookReplyPreparer) is the
worked example.
Triage while the inbox is closed
InboxTriageKeeper is the instance's OWN hosted job (maintainer, 2026-10-27: the owner runs the inbox on a laptop's local mesh;
control is not involved for a personal instance, and the cloud instance runs the identical code). Every 15 minutes
(Inbox:Triage:Background:Interval, never below a minute; Enabled, MaxOwnersPerTick, MessagesPerOwner) it lists the
instance's people as System (nodeType:User), and per owner - one after the other, a cursor rotating through a list longer than the
bound - it skips an owner who switched sorting off or has no stored mailbox grant (one preference read and one grant read, no Graph
call, no model), otherwise reads the newest messages through OutlookMailbox, reads their verdicts through the offering store (which
hands owed replies to the drafter, as an inbox read does) and gives InboxTriagePass the ones without a verdict, plus the one-time
backlog re-judgement. It reuses the pass's bounds (MaxPerPass), its create-only verdicts and its per-process attempt memory, so a second
replica or a restart writes nothing twice; ticks never overlap; one owner's failure is logged and the others still run.
The same visit deletes, once per owner and process, the obsolete conversation lock PreparedReply nodes in _Replies
(PurgeLegacyLocks: only thread: item ids with that subject and no draft - a real reply is never matched; nothing reads them any
more).
Model calls: triage, re-judgement and reply composition resolve the model with the same tier logic (Inbox:Triage:Tiers,
default utility then chat, EU residency; a named Model still has to be EU), so the laptop's local model is used when it is the
EU-marked utility tier. Classification calls ask for ReasoningEffort.None and 600 output tokens (measured on the local utility model:
0.75 s per message with thinking off against 3.7 - 17 s on, and thinking consumed the output cap and truncated the JSON at 400
tokens); reply composition asks for Low. A provider that takes no effort parameter ignores it.
Teams suggestions
A Teams chat gets triaged and a suggested reply like a mail, with two differences forced by the connection: it holds Chat.Read only (posting needs Chat.ReadWrite, which is deliberately not asked for), so nothing is ever posted to Teams; and a chat has no id per message, so the item is a turn - TeamsChatNodes.TurnId(chatId, time of the last message). A newer message is a new turn with a new verdict and a new suggestion. The keeper reads the owner's chats beside the mail (TeamsChatNodes.ToTriageInput: chats whose last message the owner wrote are skipped), the verdicts are keyed provider = teams, and the drafter routes to TeamsReplyPreparer, which reads the chat's newest messages and keeps the text on the record (IReplyPreparer.KeepsTextOnRecord, PreparedReply.SuggestionText): it never looks for an existing reply and always writes a suggestion. The chat row carries the suggestion-ready tag (the query provider joins the verdicts by turn) and the reading view (TeamsChatInboxItemReader) shows the suggestion above the conversation, with the line that nothing has been posted. Known limit: a chat that has its own room is listed by the rooms leg, which does not join verdicts.
Calendar suggestions
"For anything which should go to the calendar, suggest to put" (owner, 2026-10-27). The triage prompt has a calendar tag (an appointment, deadline or event with a date). A verdict carrying it - from any source - is offered, through the same seam as the reply drafter (ReplyOfferingVerdictStore), to the CalendarSuggester:
- Where entries come from. Outlook states some outright and these need no model: an invitation (a meeting request, read with
$expand=event; skipped when cancelled, organised by the person or already answered) becomes an Accept suggestion, never a second event;.icsattachments are parsed deterministically (IcsParser; a cancellation yields nothing). Otherwise the EU model (same tier resolution as the triage, no reasoning,CalendarPrompt) finds at most three entries in the text; the message is data. Teams chats are read through the same preparer the reply drafter uses. The shape (CalendarSuggestion: provider + source item + title + wall-clock start/end + zone + all-day + place + link) is source-agnostic: the Escola agent can file the same record. - Resolution.
CalendarTimes.Resolve: the zone the entry names, else the person's own (User.TimeZoneId), else UTC; an all-day entry ends at the midnight after its last day; nothing in the past, beyond two years, or at a wall-clock time that does not exist (a DST gap) is proposed. - Already in the calendar. Before proposing, the calendar around the entry is read (
OutlookCalendar.Around, delegated, the Executive Assistant's grant): an event that overlaps AND whose title contains, or is contained in, the suggestion's counts (overlap alone does not: two meetings can share an hour; an invitation's own pending event is not a duplicate). Then it is recorded asalready-thereand never proposed. An unreadable calendar proposes nothing this time and claims nothing. - One per entry of an item. The record
CalendarSuggestionat{user}/_Calendar/{provider}-{digest}(digest of source item, start and title) is claimed create-only; a re-triage, a restart or a second replica proposes it once, and a dismissed one is never proposed again. - Nothing is written without the click. The suggester has no calendar write at all. The record's own page (the node's Overview area; it is also what the inbox's reading view shows, because the leg lists stored nodes) shows the entry, where it came from, and two framework buttons: Add to calendar (or Accept the invitation (no reply is sent)) and Dismiss. Add creates ONE event: no attendees, so no invitation goes out, and a Graph
transactionIdderived from the suggestion, so a retry is the first event; the calendar is re-read on the click and an entry the person added meanwhile isalready-there. The transitions (suggested -> working -> added / accepted / failed, dismissed) are atomic at the node, so a double click is one write and a second click on an added suggestion is an answer. Accept usessendResponse: false. A failed write leaves the buttons and a sentence. Only the owner's click is carried out. The inbox row's Delete is a recorded dismissal, Undo takes it back; the leg lists every suggestion not yet decided.
Not built yet
- Gmail. Two prerequisites are missing, and neither is the drafter's: the Gmail source is not triaged
(no verdict is ever written for
gmail), and the Google package's consent is deliberately read-only (gmail.readonly; a draft needsgmail.composeorgmail.modify), a product decision that is not widened here. The contract is ready for it. - CRM context in the draft. The model sees the conversation, not the CRM. The assistant's
fileUnderlookup is a follow-up. - A control-queue job. The keeper and the passes run in the owner's own instance (a personal instance owns its queue; the cloud instance runs the same code), as a hosted service. The queue that runs jobs on the control instance is for work that needs the control plane; mail work needs the owner's credential and the triage code, which live where the keeper runs.
- A direct link on the row (core's inbox has no column for it), and the person's edit of a verdict
still does not stamp
correctedBy. - Calendar suggestions beyond Outlook's calendar (a Google calendar), and an Add that lets the person edit the title or time first - they edit the event in Outlook after adding.
Order of work
- Outlook: the triage job, the verdict node type, the inbox leg and the tag side menu.
- Actions on the selection (Archive, Delete, Mark read, Tag), called through Graph.
- Live search through the provider.
- Gmail, then iMessage (the Mac bridge), then the WhatsApp business channel.
What WhatsApp cannot do: WhatsApp offers no API for a person's own chats — only the business number the assistant answers on. Those conversations are triaged; a personal chat is not reachable.