Getting started and Connections
A person who has just signed up should end up with an assistant that can actually do something for them. That takes a few steps — connect their mail and calendar, find their learning path — and those steps are offered in one flow, not as separate cards from separate modules.
2026-10-17 — the Getting-started page is now the setup wizard. The assistant introduces itself and walks the person chapter by chapter through the panels packages contribute as data (
UiContribution, contextOnboarding); the steps below are one chapter of it, on the same ledger. The chat lines and the Connections layer are unchanged.
Getting started: one list, three places
There is one ordered list of steps per person. Where it shows:
| Where | What | When |
|---|---|---|
| After onboarding | the Getting-started page, /{you}/GettingStarted — every open step, in order, with Start and Not now |
once, right after the sign-up form (unless the person arrived through a deep link, which wins) |
| Every new chat | one line: the next open step | while any step is open |
| Every new chat | one tip line — today: type /feedback to tell us anything |
until the person dismisses it |
A step never returns once the person did it or clicked Not now; the next one moves up. The
per-person record is AiSettings.DoneNudges on their own {you}/_Memex/AiSettings node — a
dictionary, for the same merge-safety reason as User.CompletedLogonActions.
Where steps come from
- Modules contribute them in code —
IChatNudgeSource, registered from DI (AddSingleton), the same lane as agent plugins. A source emits its step only while it applies and an empty list once it does not; it never throws. Built in: Connect your accounts (while nothing is connected and the installation offers at least one connection) and the/feedbacktip. The Learning Roadmap contributes Find your learning path (Edu module,MeshWeaver.Courses). - Admins declare them as data — a
ChatNudgenode atAdmin/_ChatNudge/{id}, like logon actions: no code, no release. Read as the system identity (users have no grant underAdmin, and an RLS-filtered read would silently come back empty). - Each step has an
Order, an optionalTranslations["de"], what choosing it does (open a page, or put a slash command into the chat composer — never send it), and whether choosing it already counts as done (DoneWhenChosen, off when "opened" is not "finished").
Connections
Where your assistant is reachable, and push per thread: the Messaging tab's top section lets a person turn channels on (web, Teams, WhatsApp, iMessage), each conversation chooses which of them its replies are pushed to, and routing rules send a topic to a channel — see Where your assistant is reachable. Channel health reuses the providers below wherever a channel's "linked" means the same as its connection's "connected".
Four sign-in flows existed before this layer — Microsoft (the Executive Assistant's consent),
LinkedIn, GitHub, instance-to-instance — each storing its credential its own way, and one kept an
access token unencrypted (SocialMedia#174). Connections is the one layer every provider now plugs
into, in MeshWeaver.AI.Connections so both compiled modules and node-native packages can bind it.
- A module registers an
IConnectionProvider: name, icon, what it enables, its kind, who may own it (a person, or a Space — one company account for a team), whether this installation configured it, a status probe, aVerifyfor "Test connection", and where "Connect" leads. - Four states, never guessed: Connected · Not connected · Rejected (the provider refused) · Couldn't tell. "Couldn't tell" is never folded into "not connected" — that tells a connected person to reconnect.
- Three credential kinds: OAuth (sign in with the provider), a credential set (named fields, some secret, with per-connection endpoints such as a test host), and a device link (WhatsApp's code).
- Stored at
{owner}/_Connections/{provider}as aConnectionCredential. Every secret goes throughConnectionSecrets.Prepare: a secret that cannot be encrypted is refused, never stored in the clear; a blank field keeps what is stored; the record'sToStringis redacted. - Agents use typed tools, never a free-form "call any URL with my token", and anything
irreversible is off until the installation turns it on (the
Teams:AgentSendpattern). - The Connections page,
/{you}/Connections, shows one tile per provider with its state and the single action that state calls for (Connect, Fix, Retry, Manage), grouped by kind.
The first providers were Microsoft 365 and Teams, wrapping the existing consent — nobody
reconnects. Phase 3 of Apps live on the instance (Store/AppsOnTheInstance §5–§7) added the rest;
see the next section.
Connect what you already use — any provider (phase 3)
Maintainer, 2026-10-10: "we should offer the connect to mail / calendar, home entertainment, … in setup phase. not just google, but any email, linkedin, …"
One list, four headings, three places
Every provider names its heading — ConnectionDescriptor.Group (an init property, so modules
compiled against the older constructor still bind):
| Heading | Providers |
|---|---|
| Mail & calendar | Microsoft 365 · Google · iCloud · Other email (IMAP/SMTP) · Other calendar (CalDAV or an .ics link) |
| Professional & social | |
| Messaging | Teams · WhatsApp · iMessage |
| Home & entertainment | Home Assistant · Apple Music · Weather |
A tile (2026-10-30, "same for the other menu"): the icon and the name, the summary in body size
and a muted colour under it, then — only when it ADDS something — the provider's reason ("Microsoft
sign-in is not set up on this deployment", never "Google is not connected yet." beside the badge:
ConnectionHealth.RestatesState), the accounts, and one bottom line with the state badge and the
action. Every group lays its tiles out in tile flow with the SAME minimum width
(ConnectionTileMinWidth, 260px), so columns are as wide in Messaging as in Mail & calendar and a
lone tile keeps its column; the tiles of a row share its height, and the summary takes the slack, so
the actions sit on one line. Markdown inside a card sits on the card: the platform's MarkdownView
paints no background of its own (it used to paint --neutral-layer-1, a darker box behind every line
of text on a card).
The SAME grouped tiles (GettingStartedAreas.ConnectionTiles) render in three places — never a second
list:
- Setup › Connect — the Getting-started page renders the Connect your accounts step as the tiles themselves, every kind at once, each optional, with Skip for now.
- Settings › Connections — the person app (
/{you}/Settings) gains a Connections rail group: Accounts (ConnectedAccounts, every heading but Messaging), Messaging (Messaging), and MCP servers (McpClients— the id kept so old links open it; contributed by theMcpmodule — the person's own REMOTE MCP servers, Add MCP server, one-tap suggestions, and below them the PUBLIC server address of this portal, fromBrowseOrigin(neverMcp:BaseUrl, the in-cluster transport), and a pointer to API tokens; a deployment that does not publish/mcpshows no such tab — see MCP servers: sign in in the browser below). Compiled tabs throughPersonApp.PersonOnlyTab, labels in the viewer's language,Updateon the person's own root. /{you}/Connections— the full page, unchanged in place, now grouped.
The chat line Connect your accounts now leads to Settings › Connections › Accounts. There is no Maps tab in the person app: map keys are instance configuration (Administration › Maps), and people learn maps in the Examples app. None existed to remove.
A provider whose availability takes a read (IConnectionProvider.ObserveAvailable, default
IsAvailable) shows "Checking…" until it answers and is dropped if it says no, faults or does not
answer in time — never holding up the other rows.
Any provider: the credential-set connectors
ICredentialSetProvider (in MeshWeaver.AI.Connections) is a credential set whose FORM the platform
renders: the provider lists its CredentialFields and implements one typed Probe; the base class
CredentialSetConnectionProvider supplies the four states, Test connection and Connect.
- Storage: ONLY
{user}/_Connections/{provider}, aConnectionCredentialnode (registered byAddConnectionCredentialType, non-satellite, the person's own partition). Every secret goes throughConnectionSecrets.Prepare;ConnectionCredentialStore.Refusalsdecides BEFORE any read or write, so a secret that would be stored in the clear writes nothing, not even the plain fields — and the write itself aborts too if encryption fails between the check and the write. - The form:
/{you}/ConnectionSetup/{provider}?return={local path}(GettingStartedAreas.ConnectionSetupView). A write-only form, not a node replica: plain fields seeded from the record, secrets always empty (blank keeps); typed secrets are cleared from the page buffer the moment they are stored; Save writes only the plain fields the person CHANGED since the last save (a field someone else changed meanwhile is never overwritten with a stale copy), then tests at once. A store read that fails shows an error and no form — an empty form would turn Save into a removal. Back returns to where the person came from (Setup or the Settings tab); only a local path is accepted, never an open redirect. - States: a complete record is NOT "Connected" by being saved — the catalog shows the last
Test connection answer kept on the record (
LastTest,LastTestReason,LastTestAt, written byConnectionCredentialStore.RecordTestafter every save and test, and only against the version that was probed — a slow test of an older save never stamps a newer one), and only while that test is no older than the last save; untested or changed since → Couldn't tell. A present optional secret that will not decrypt is Couldn't tell, never silently dropped (the probe would otherwise test a different credential than the one stored). - Other email (
imap, Mail module): user, IMAP host/port, optional SMTP host/port, password or app password. Test signs in with MailKit'sImapClient(andSmtpClientonly after IMAP signed in) — TLS on 993/465, a REQUIRED STARTTLS otherwise, never a clear-text sign-in — and disconnects; nothing is read or sent. MailKit 4.18.0 is pinned centrally in core (MeshWeaver#6444). 🚨 Today the connection is saved and tested only: the assistant's mail tools still work through Microsoft 365 and do not read an IMAP mailbox yet, so the card says "save and test its sign-in", nothing more. - Other calendar (
caldav, Mail module): an address and an optional user/password. The ADDRESS is a secret too — a private subscription link carries its bearer token in the URL — and an address with a user name or password in it is refused, as is half a sign-in (a user without a password, or the reverse). Test sends the iCloud plugin's own discovery request (PROPFIND current-user-principal, Depth 0) to a CalDAV server, or aGETto a subscribed.icslink;webcal://is HTTPS, plainhttp://is refused. The logic is re-stated in compiled code rather than shared, because node-nativeSource/stays self-contained. Like Other email, it is saved and tested only today: no assistant tool reads this calendar yet, and the card says so. - 🚨 No private network. Both probes run in an
IIoPoolleaf and resolve the host ONCE:PublicHostrefuses any address that is loopback, private, link-local (the cloud metadata endpoint), carrier-grade NAT, unique-local — an ALLOW-list of global unicast, so every IANA special-purpose block (benchmarking, documentation, 6to4, Teredo, NAT64, …) is refused — then connects a socket to a VETTED address — MailKit gets that socket plus the host name for TLS only, and the calendar's HTTP client connects through the same callback with redirects OFF — so neither DNS rebinding nor a redirect to a private address reaches the cluster. The answer body is read up to 64 KiB, never buffered whole.
Wrapped, never re-implemented: flows that already exist
Node-native packages cannot register a DI service, so NodeBackedConnection wraps their existing
flow from the AI layer (as Microsoft 365 wraps the Executive Assistant's consent):
| Provider | Offered while | Connected when | Connect leads to |
|---|---|---|---|
Google installed |
a Google/Account node under you has clientId, clientSecret, refreshToken, or a broker binding |
/{you}/AddMailAccount (Add mail account: no store cover, see Google/ConnectViaMother) |
|
| iCloud | ICloud installed |
an ICloud/Calendar node has appleId, appPassword |
/ICloud |
LinkedIn installed |
{you}/_ApiCredentials/linkedin has accessToken |
/connect/linkedin?profile={you} (the Social module's OAuth) |
|
| Home Assistant | HomeAssistant installed |
a HomeAssistant/Home node has baseUrl, token |
/HomeAssistant |
| Apple Music | AppleMusic installed |
an AppleMusic/Music node has musicUserToken AND either its key trio or a developerToken (the package's own IsConfigured) |
/AppleMusic |
| Weather | AppleWeather installed |
{you}/_Connections/weather (the ConnectionCredential the Weather package writes since #3316) has teamId, keyId, serviceId and the privateKey secret set — or, while instances migrate, the legacy AppleWeather/Weather home copy does (the better answer wins) |
/AppleWeather |
A node whose content will not read is Couldn't tell, never reconnect advice. They READ only — whether a member is set, never its value — the package root as the system identity
(installed is platform configuration) and the owner's nodes as the owner. WhatsApp wraps the link
flow in its own module (WhatsAppConnectionProvider, Link, Connect continues in chat).
Coding harness accounts: Claude Code in the browser (2026-11-06)
Owner: "same for claude code there", "i will connect from my rn app" — sign-ins happen in the
browser, never in a terminal or an SSH paste. Under Developer tools, after GitHub, each coding
harness that authenticates per person gets a tile — Claude Code first ("Use your Claude subscription
for your threads"), then OpenAI Codex from the same class (Connect/HarnessAccountConnectionProvider).
Two harnesses are deliberately NOT offered yet, because their account login cannot run in the browser:
Cursor (its agent declares no device login; it takes an API key in the chat's /login) and
GitHub Copilot (CopilotConnectStrategy scrapes a device code from a CLI that prints nothing
without a terminal, and the scrape ends in an error after the challenge — TODO(copilot-pty)). Each
joins Offered in the change that makes its flow work, with a test.
| Offered while | a connect strategy is registered for the provider (ConnectSessionManager.Supports) and a harness declares it as its AuthProvider — no harness, no thread could use the login |
| Connected when | the CLI's own login in the harness's per-user dir (IHarness.UserConfigDir, Claude Code's CLAUDE_CONFIG_DIR) answers yes, or a pasted token is stored at {you}/_Memex/{Provider}; a probe that faults is Couldn't tell |
| Connect leads to | /{you}/HarnessLogin/{Provider}?return=… (local return only) |
The HarnessLogin page (Connect/HarnessLoginArea, platform controls only) drives the same
ConnectSessionManager as the chat's /login dialog: Sign in starts the CLI's login; the page shows
Open the sign-in page (a button that opens the provider's authorize URL in a NEW tab, so the page
keeps the session) and, for a paste-code flow (Claude Code), a one-line code field and Submit; a
device-flow provider (Codex) shows its user code and completes on its own. Connected offers Sign in
again, an error Try again, a live session Cancel. Claude Code also takes a pasted
claude setup-token (Save token, SubmitToken). English and German.
While the account reads Signed in (a CLI login or a stored token) the page offers Sign out. It runs
Connect/HarnessSignOut — the ONE sign-out, which the Blazor composer's /logout calls too: it cancels a
sign-in still running (so a late completion cannot store a login afterwards), deletes the CLI's cached login
files (IHarness.CredentialFileNames under IHarness.UserConfigDir, on the FileSystem I/O pool) and
deletes the stored credential {you}/_Memex/{harness}. The outcome line is honest: "Signed out — your
stored login … was removed" only when something was removed, else "no login was stored"; a cached login
file that cannot be deleted (or checked) is an ERROR, never a "signed out" — the CLI would go on
authenticating from it. So is a harness that caches a login but declares no per-person config dir: its CLI
runs on the machine's own login, which a per-person sign-out must not and cannot clear. The React and
React Native composers have no sign-out of their own: /logout under an offered harness opens this page.
Sessions are in memory on the replica that started them (the manager's table, five-minute limit),
so the page starts and submits on the same person's hub; a session that is gone — timed out, the portal
restarted — reads "This sign-in is no longer running … Start again", never a hang. Tests:
MeshWeaver.AI.Test/HarnessLoginTest (availability, the three states, the Connect target, and the page
rendered and clicked: Start → link + code → Connected; bad code → Try again; Cancel; lost session; Sign out
deletes the cached files and the stored credential, says so honestly, and is not offered while signed out).
The tests click the way the GUI does — stream.SubmitUserAction on the page's own stream — because that is
what orders a click behind a field written just before it (the write is queued on the stream's hub, and
its patch leaves first). A click posted from the client hub has no such order: under CPU load it reached
the owner ahead of the pasted code, the click action read an empty field, and the page correctly showed
nothing new — the 3–6 % "click recorded, next frame never arrives" flake (2026-11-09). Waiting for the
value on the local stream does not close that race: a new subscriber sees the value before the stream's
own patch forwarder has run. ATokenPastedAndSavedInOneTurn_IsTheTokenThatIsSaved pins the order.
What is not done, and why
- X and YouTube are not listed: neither package has a connect flow to wrap (both read the data archive the person downloads), and building an OAuth flow for them is new work, not a wrapper.
- Several accounts exist for the credential-set providers and Microsoft 365. Google needs the follow-up in Several accounts per kind below.
- The wrapped packages still keep their credentials in their own nodes (Google's, iCloud's, …),
not encrypted under
_Connections; moving them is a change to each package.
The recipe for adding a provider is the built-in skill /build-a-connection; the user-facing
guide is /connect.
MCP servers: sign in in the browser, the portal holds the token (2026-11)
Maintainer: "add mcp", "we can then just enter credential in browser" — no terminal, no pasting a token into SSH, and the Memex app works the same way because the sign-in happens in its browser.
What the person does. Settings › Connections › MCP servers → type the server's address (and optionally a name) → Add and sign in, or tap one of the suggestions. The browser goes to the server's own sign-in, comes back to the tab, and the server is listed with its state (Connected, Connected — refreshes on next use, Expired, Cannot be read) and a Remove button. Every Claude Code thread of that person then connects to it, with its tools allowed.
The flow (src/MeshWeaver.Mcp/McpServerConnectEndpoints.cs, client in
src/MeshWeaver.AI/McpServers/McpOAuthClient.cs) follows the MCP authorization spec:
GET /connect/mcp?server=<url>&name=&returnPath=(signed in) only shows a confirmation page — which server, under which name, what connecting it means. The flow starts on its button, aPOST /connect/mcp/start?t=…whose token only that page carries (DataProtection, time-limited, bound to the signed-in person and that exact server), so a link anyone can put on any page can never add a server by itself. The address must behttps://(plainhttponly for a loopback or*.localhosthost: the token travels on every request). 🚨 Every discovery, registration and token request goes through a vetted, no-redirect transport (PublicHost, moved into the AI engine from the Mail module so there is ONE rule): only hosts whose every address is public are contacted, so a person-typed address cannot probe the cluster's network or a metadata endpoint. A local development mesh setsMcp:Client:AllowPrivateNetworks: true(still no redirects).- Discovery: probe the server for its
401+WWW-Authenticate: Bearer resource_metadata="…", else/.well-known/oauth-protected-resource{path}, else/.well-known/oauth-protected-resource(RFC 9728) → itsauthorization_servers[0]→ that server's RFC 8414 / OIDC metadata (path-inserted forms first for an issuer with a path). A server with no protected-resource metadata at all is its own authorization server (the 2025-03-26 spec), with/authorize,/token,/registeron its origin as the last resort. A server that lists PKCE methods withoutS256is refused, so is one whose advertisedresourceis not itself (same scheme, host, port, and the server's path at or under it — otherwise a token for ANOTHER service would be stored for, and sent to, this one), and so is any discovered OAuth endpoint that is nothttps://(the code, verifier, refresh token and client secret travel to it). - Client: dynamic client registration (RFC 7591) as a PUBLIC client
(
token_endpoint_auth_method: none, grant typesauthorization_code+refresh_token) with the portal's own callback asredirect_uri, when the server offersregistration_endpoint; else the configuredMcp:Client:ClientId(defaultmeshweaver-portal). - Redirect to
authorization_endpointwith PKCE S256,state, the RFC 8707resourceand the resource'sscopes_supported. The callback's whole working set (state, the person, server, resource, token endpoint, client, verifier, return path) rides a DataProtection-protected cookie scoped to/connect/mcp, so a forged cookie cannot steer the token POST anywhere. GET /connect/mcp/callback— the CSRF state is checked BEFORE the server'serror, the cookie must belong to the person now signed in, and the cookie is consumed only once both match (a cross-site hit with a wrong state cannot destroy the genuine return); the code is exchanged (verifier + resource), and the tokens are stored. Every outcome lands back on the (local-only) return path withconnect=mcp-okorconnect=mcp-error&reason=…, which the tab shows as a notice.
The record — one account per server in the person's ConnectionCredentialStore,
{user}/_Connections/mcp--{hex}: Fields url, name, clientId, resource, expiresAt (Unix
seconds, UTC); Secrets accessToken, refreshToken, clientSecret — enc: values or nothing (a
deployment with no master key stores NOTHING and the person is told so); Endpoint tokenEndpoint. A
second sign-in to the same address updates that account instead of adding a second one, and
REPLACES its grant: a refresh token or client secret the new sign-in did not issue is removed, not
carried over. Addresses are compared after normalising (scheme and host lowercased, no trailing
slash on the path, the query untouched) and then ordinally — /Team/mcp and /team/mcp are two
servers. Two sign-ins that race from two devices can still create two accounts for one address; a
round then uses only the newest, and the list shows both so the extra one can be removed.
Refresh on expiry (McpServerAccounts.Fresh): a token within one minute of its expiry is
refreshed through the stored token endpoint (one IIoPool HTTP leaf) and written back before the
round uses it; a server that does not rotate refresh tokens keeps the stored one; an explicit
expires_in: 0 is an already-expired token. Two rounds of one replica that find the same expired
token share ONE redemption, and the write-back happens only while the record still holds the refresh
token that was redeemed, so an older answer never overwrites a newer sign-in or another replica's
rotated pair. Not done: serialising refresh ACROSS replicas — two replicas can still redeem the
same token at once, and a server with strict single-use rotation then refuses the second (that round
runs without the server; the record keeps the first replica's pair). An expired token with nothing to
refresh it with is never handed to the CLI — the list says Expired. The refresh is ONE operation per
account per replica (McpOAuthClient.SingleFlight): it re-reads the record from its owning hub,
uses a token another round already wrote, and releases only after its write-back — so a round that
read the list before that write-back never redeems the same refresh token again.
What is re-checked on the STORED record (it is owner-editable, so it is never trusted as a
discovery result): the token endpoint must be https:// at the moment a refresh token or client
secret is sent to it; the server address must pass the same rule as sign-in (McpServerAccounts.Problem)
before its bearer is revealed for a round, and the Claude Code harness drops any server that fails it
before writing --mcp-config. A token that no longer decrypts (the key changed) reads Cannot be read
in the list, checked server-side. A sign-in's fields, tokens, token endpoint and secret removals are
ONE write of the record. The OAuth transport keeps no cookies (it is process-wide) and buffers at most
1 MiB of any answer. Not covered: the CLI connects to the server with its OWN transport, so the
vetted-address guard does not run on those connections — a server that re-points its DNS to a private
address after sign-in is not stopped there; the address rule above is what holds on that path.
Suggestions are DECLARED, never coded: Mcp:SuggestedServers is a list of { Name, Url } in the
deployment's configuration (for example the control instance and the public instance of a fleet),
empty by default; a suggestion already connected is not offered again. Sample:
"Mcp": { "SuggestedServers": [ { "Name": "Company portal", "Url": "https://portal.example.com/mcp" } ] }
How the Claude Code harness uses the servers: ClaudeCode/HeadlessProtocol → The person's own MCP
servers.
Several accounts per kind (2026-10-12)
Maintainer, 2026-10-12: "centralize all the logins … let me add all my emails". The design of record (Store/AppsOnTheInstance §6) has said "every account the assistant may use, any provider, several per kind" since phase 3. This section is that rule, made concrete.
The record: one node per ACCOUNT, and ids are never reused
A credential-set account lives at {owner}/_Connections/{account}, typed ConnectionCredential.
- The record written before several accounts existed keeps the bare provider id (
imap,caldav), read and edited exactly as before: no migration, no rewrite, and no secret is re-encrypted. That id is never created anew, by any caller: the store's create refuses it (LegacyIdProblem), the seven-argumentSaveincluded. A setup page opened on it while it is absent (never stored, or removed) opens a NEW account instead. - Every account created since is
{provider}--{10 random hex}(NewAccountId) and carries its provider inConnectionCredential.Provider. So no id is ever reused: a page left open on a removed account can only address that account. Removing, saving or testing from it can never touch a newer one, and none of those needs a version or generation check.ProviderOfreads an id: a suffix that is not exactly ten lowercase hex characters is part of the provider id, never a half-parsed account. - A form opened against an absent account is create-only (
AccountWrite.Create). Its first save is the create itself, never decided from the query index: the owning hub refuses a duplicate, which reads asAccountTakenProblem. It never updates a record that appeared meanwhile. - A form opened against a stored account is
AccountWrite.Update: it updates, or refuses withAccountGoneProblemrather than write a removed account back. "Gone" is never read off a lagging index. A record the index does not show yet getsIndexGrace(5 s) to appear, which covers a save right after the create. An update that fails is classified by a fresh read after the failure. Once the page has created its record, its later saves update it. ObserveAccountslists a provider's accounts (legacy record first, then by creation; theOrdinalis display only) through the caller hub's SYNCED query (hub.GetQuery). Every emission is the complete set, deserialised through that hub's registry. Content is never read off raw query rows, which are metadata-only projections. The single-recordObservefolds the raw change feed by path (Fold). A record whose content will not read is listed with no credential and reads Couldn't tell. It is never dropped, because a missing row would read as "not connected".Removedeletes that one node: no other account, no grant.- The provider's state is the best of its accounts' states (
AccountsStatus: Connected, then Couldn't tell, then Rejected, then Not connected), so one working mailbox reads Connected and one broken mailbox does not hide it. The production providers read every account (AccountsRead = ConnectionCredentialStore.AccountsReader(services)).
The surfaces
- Settings › Connections › Accounts: under each credential-set tile (Other email, Other calendar), Your accounts lists every stored account. Each row is a button carrying its label as text (the address or user name; else Other email 2), which opens its own form, plus a badge with its state. Controls, never Markdown: a label is the person's input and stays text. Add another account opens a form for a new id. The tile's badge is the best state over the accounts, and its action opens the first account (a new one when none is stored). A failed read of the list is shown as one ("could not be read"), never as an empty list.
- The setup page (
/{user}/ConnectionSetup/{account}) is per account: Save, Test connection and the state all address that account's record. It adds Remove this account and Add another account, both only once this account is stored: before that there is nothing to remove.
What the assistant reads
No assistant tool reads the credential-set accounts yet: Other email and Other calendar are saved
and tested, and the mail and calendar tools still go through Microsoft 365. Whoever adds the first
reader must enumerate ObserveAccounts and name the account in every answer. Never read only
{provider}, which is only the pre-existing record.
Microsoft 365: several mailboxes (2026-10-13)
Done as a core + Plugins pair (core Doc/AI/ExecutiveAssistant → Several mailboxes holds the
storage and consent design). What changed against the plan above:
- The record. The real path was never
{user}/_EaCredential/ea-credential: a person's grant lives atAuth/_EaCredential/{user}. That record is the primary mailbox, untouched; each mailbox added since is a child,Auth/_EaCredential/{user}/ea-credential--{10 hex}, never reused. The primary is EMPTIED on removal, never deleted, because deleting it would cascade to the added mailboxes beneath it.EaCredential.Address(read off the consent's id token) is the label. - Consent.
/auth/ea/connect?account=newalways runs the consent withprompt=select_account;?account={id}reconnects one mailbox with its address aslogin_hint. A new consent for an address that is already connected reconnects that mailbox, never a duplicate. - The assistant (
ExecutiveAssistantPlugin). Every mail and calendar tool takes an optionalmailbox(an address or account id);ListMailboxeslists them. No fan-out: without amailboxthe tools use the primary, and only when the primary is NOT connected (a completed read) the first connected added mailbox; an undetermined primary stays undetermined. A named mailbox that does not exist is said, with the connected ones, never silently replaced by the default. A draft lives in the mailbox it was made in. Drafting stays the only mail write; Teams keeps using the primary grant. - Settings › Connections.
IMultiAccountConnectionProvider(MeshWeaver.AI.Connections) is the surface for a provider whose own flow connects several accounts;Microsoft365ConnectionProviderimplements it. Under the tile, Your accounts lists each mailbox: its address as text, its state, default on the one the assistant uses unasked, Reconnect when it is not connected (not for couldn't tell), Remove, and Add another account. The tile's state is the best over the mailboxes (ConnectionHealth.Best). - Compatibility. Every new
IEaGraphAuthmember has a default serving the primary only, so a host or fake written before this keeps its single-mailbox behaviour; nobody migrates or reconnects.
Google: what several accounts needs (not done here)
- Google is a
Google/AccountNODE that holds its own OAuth client and refresh token, and the wrapper already accepts any node of that type under the owner (StatusPath: null). Several accounts needs two things:- The wrapper lists every such node (today it answers one state for the type).
- A way to create a second one. Creating a
Google/Accountin a person's home is refused by the home's creatable types (CreatableTypes), so the package must offer an Add account action that files the node through its own hub, rather than relying on the generic Create form.
This is a follow-up, tracked from this page.
What was decided, and why
- One flow, not two cards. The Learning Roadmap's standalone "take your educational journey" card was withdrawn in favour of a step in this list (coordinated with its session, PR #1625).
- Show on every new chat, not only the Executive Assistant's — new chats default to the generic Assistant, so gating on the EA would hide the steps from most people.
- Onboarding is untouched except its last redirect. It is a single form whose completion is the Active user node; the Getting-started page is reached after it, on the new person's own node.
- Every surface renders only for its owner. The areas live on the user node and show nothing to a visitor, so embedding them can never show one person's steps or connections to another.