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, context Onboarding); 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

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.

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 LinkedIn
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:

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.

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 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 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

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:

  1. 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, a POST /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 be https:// (plain http only for a loopback or *.localhost host: 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 sets Mcp:Client:AllowPrivateNetworks: true (still no redirects).
  2. 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) → its authorization_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, /register on its origin as the last resort. A server that lists PKCE methods without S256 is refused, so is one whose advertised resource is 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 not https:// (the code, verifier, refresh token and client secret travel to it).
  3. Client: dynamic client registration (RFC 7591) as a PUBLIC client (token_endpoint_auth_method: none, grant types authorization_code + refresh_token) with the portal's own callback as redirect_uri, when the server offers registration_endpoint; else the configured Mcp:Client:ClientId (default meshweaver-portal).
  4. Redirect to authorization_endpoint with PKCE S256, state, the RFC 8707 resource and the resource's scopes_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.
  5. GET /connect/mcp/callback — the CSRF state is checked BEFORE the server's error, 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 with connect=mcp-ok or connect=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 surfaces

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:

Google: what several accounts needs (not done here)

This is a follow-up, tracked from this page.

What was decided, and why