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.
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 |
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 clients (McpClients, contributed by theMcpmodule — the PUBLIC server address, fromBrowseOrigin(neverMcp:BaseUrl, the in-cluster transport), and a pointer to API tokens; a deployment that does not publish/mcpshows no such tab). 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 |
/Google |
|
| 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).
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.
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.