atlas-portfolio — architecture¶
Customer 360 + M365/Copilot/Agent 365 MSX pipeline view for Sush's ABS-team specialist book. Static HTML, generated by a node script that spawns
msx-mcpas a stdio subprocess. Third time was the charm after PAC and Atlas CC were paused.
Repo: C:\ssClawy\atlas-portfolio\ · GitHub: ssutheesh_microsoft/atlas-portfolio (private)
Built: 2026-05-24 (single session, ~5 hours, four phases shipped end-to-end)
Phase queue: ~/.copilot/atlas-portfolio-phases.md (trigger: execute next atlas-portfolio phase)
Predecessors (archived):
- ssutheesh_microsoft/pac-archive @ v0.6.1-archived (Personal Atlas Cockpit — paused)
- ssutheesh_microsoft/atlas-cc-archive @ v0.4.0-archived (Atlas CC — paused)
🔴 Standing rules (any future session touching atlas-portfolio MUST honour)¶
- Never load mock data. Ever. Real MSX only. Real customer names, real opp values, real TPIDs. If you can't pull real data (auth issue, VPN issue, MCP issue), surface the failure clearly and stop — do NOT fabricate sample data to "show the shape". Per Sush 2026-05-24: "please don't load mockup data ever — only real data."
- Never use manual overrides. Always through systems. If MSX is missing data (e.g., SSP not on team roster), find another system signal (opp ownership, deal-team membership, etc.) — don't create a hand-curated mapping file. Per Sush 2026-05-24: "we will try to avoid manual overrides - always through systems - we will find different avenues to do it."
- Scope is M365 / Copilot / Agent 365 only. No ACR (Azure consumption), no MACC, no Azure cross-sell. Sush is on the ABS team (AI Business Solutions) working with 3 specific SSPs.
- Microsoft Confidential data stays on local disk.
data/anddist/*.htmlare gitignored. Snapshots never leave the laptop. - No Electron. No build step. Pure static HTML + a generator script. The whole point of atlas-portfolio is to be the opposite of what made PAC + Atlas CC painful.
👥 SSP context (Sush's 3 named specialists)¶
| Name (MSX spelling) | Pronoun | UPN | qualifier2 signature | Vertical coverage |
|---|---|---|---|---|
| Tam Bagnall | she | TBD | AI Biz Sol-* | NZ Government + public sector (~22 customers) |
| Riki Plester | he | riples@microsoft.com |
AI Biz Sol-AI Workforce | Education + FSI banks (~17 customers; also owns MoH opps despite not being on team roster) |
| Ben Brown | he | TBD | AI Biz Sol-AI Workforce | Private corporates (~14 customers) |
Notes: - Sush originally spelled it "Plaester" — MSX has "Plester" (no second 'a'). Always use MSX spelling in code; correct pronouns in prose. - SSP detection uses TWO signals: team-roster fullname match AND opp-owner fullname match. The OR of both is the working SSP definition.
Why "Path B" — the static-HTML / no-Electron choice¶
PAC (Personal Atlas Cockpit) and Atlas CC each spent ~10–14 days getting stuck on Electron + MSAL Corp Conditional Access walls before being paused on 2026-05-24. The architecture gate for the successor surfaced 5 paths:
| Path | Surface | Days to ship | Auth pain | PAC character? | Risk |
|---|---|---|---|---|---|
| A | Copilot CLI skill (markdown out) | 1-2 | Zero | Limited | Low |
| B ★ | Static HTML report (node script) | 3-5 | Zero | Full | Low |
| C | Lightweight Electron mini-app | 7-10 | Low | Full | Repeats Atlas CC trap |
| D | Hybrid: B + A (HTML + CLI drill) | 5-7 | Zero | Full | Low |
| E | Power BI + Copilot wrapper | weeks | n/a | None | Too heavy |
Path B won because:
1. The "PAC character" is visual (CSS + typography + tone), not Electron-shaped.
2. The big auth unlock landed: az login (Azure CLI), not MSAL. Frontier SE / msx-mcp / ai-sales-kit all use Azure CLI auth. No Corp Conditional Access wall, no admin-consent dance, no MSAL token caching, no broker/WAM. Sush already has az login from countless workflows.
3. Third-time's-the-charm wanted the opposite of the complexity that paused two projects.
v0.1 actually shipped in ~5 hours across four phases in a single session — significantly faster than the Path B estimate. The estimate was wrong because the rubber-duck-first pattern killed most rework. See "Why this shipped so fast" below.
Stack at a glance¶
[ az login ]
│ (Azure CLI auth)
▼
[ msx-mcp stdio subprocess ] ◄── @mcaps-microsoft/msx-mcp v0.17.1
│ (JSON-RPC over stdio) C:\ssClawy\msft-internal-mcps\msx-mcp\
▼
[ @modelcontextprotocol/sdk Client ] ◄── official MCP SDK, NOT hand-rolled
│
▼
[ scripts/generate.js (node, ESM) ] ◄── single entry point
│
├── checkAuthStatus() — msx_auth_status preflight (no login prompt)
├── fetchCustomerList() — get_account_team(format='compact')
├── enrichCustomersWithOpps() — 54× get_account_overview parallel (concurrency=8)
└── renderDashboard() — JSON → HTML
│
▼
[ dist/index.html ] ◄── 105 KB single-file dashboard, opens in browser
End-to-end runtime (v0.1): ~41 s for 54 customers + 1,080 opps + JSON snapshot + HTML render. Acceptance was <90s for ~30 customers.
The msx-mcp subprocess pattern (the canonical one — lift this for any future tool)¶
// src/mcp-client.js — thin wrapper over @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const env = { ...process.env };
// Defensive scrub — if atlas-portfolio is ever spawned from an Electron parent,
// these vars would corrupt the child Node process (lift from PAC's workiq.ts).
delete env.ELECTRON_RUN_AS_NODE;
delete env.ELECTRON_NO_ASAR;
delete env.ELECTRON_NO_ATTACH_CONSOLE;
const transport = new StdioClientTransport({
command: process.execPath, // absolute node binary path
args: ["C:\\...\\msx-mcp\\dist\\index.js"],
env,
stderr: "pipe", // msx-mcp logs to stderr; stdout is JSON-RPC
});
const client = new Client(
{ name: "atlas-portfolio", version: "0.1.0" },
{ capabilities: {} }
);
await client.connect(transport); // handles initialize + initialized internally
// Sanity gates after connect:
const serverInfo = client.getServerVersion();
if (serverInfo?.name !== "@mcaps-microsoft/msx-mcp") throw new Error("Wrong server");
const { tools } = await client.listTools();
for (const required of ["msx_auth_status", "get_account_team"]) {
if (!tools.find(t => t.name === required)) throw new Error(`Missing tool: ${required}`);
}
// Now safe to call:
const result = await client.callTool({ name: "get_account_team", arguments: { format: "compact" } });
if (result.isError) throw new Error(`Tool failed: ${result.content?.[0]?.text}`);
const text = result.content.filter(c => c.type === "text").map(c => c.text).join("\n");
Don't hand-roll JSON-RPC. The official SDK handles framing (newline-delimited JSON over stdio), request/response correlation, error envelopes, and the initialize handshake. ~100 lines saved + a class of bugs avoided.
The auth-preflight pattern (avoid surprise az login prompts mid-script)¶
The msx-mcp AzureCliAuthProvider can trigger az login interactively on token expiry. To avoid that:
- Call
msx_auth_statusFIRST. It is explicitly designed to check auth WITHOUT triggering a login. - Parse its rendered markdown for
❌/⛔emoji → if present, surface diagnostic + exit. Don't proceed to data tools. - Categorize the failure mode and give friendly hint (see VPN gotcha below).
const result = await client.callTool({ name: "msx_auth_status", arguments: {} });
const text = result.content[0].text;
const ok = !/[❌⛔]/.test(text);
if (!ok) { surfaceDiagnostic(text); process.exit(2); }
Atlas does NOT call az login automatically. Surprising the user with a browser sign-in mid-pipeline is the wrong UX. Tell them what to run; they run it; re-execute. Honest, predictable.
VPN profile gotcha (will bite the next person too)¶
Microsoft has multiple VPN profiles installed on corp laptops. They egress to different IP ranges. Only some of them are on the MSX Power Platform's IP allowlist.
| VPN Profile | Public IP egress | MSX prod (microsoftsales.crm.dynamics.com) |
MSX UAT (msxuat.crm.dynamics.com) |
|---|---|---|---|
MSFTVPN-Manual |
corp range | ❌ blocked (0x80095ffe / "IP address is blocked") |
✅ works |
MSFT-AzVPN-Manual |
Azure VPN egress | ✅ works | ✅ works |
The msx-mcp msx_auth_status diagnostic misclassifies this — it says "❌ Access: Forbidden" and tells you to "request access from your manager or IT", which is misleading because the actual error in the raw az rest response is "your IP address is blocked".
atlas-portfolio detects this case explicitly:
if (/IP\s+address\s+is\s+blocked|0x80095ffe/i.test(text)) {
hint = "vpn";
// → "Connect to Microsoft VPN (Global Protect or equivalent), then re-run.
// The msx-mcp diagnostic may say 'Access: Forbidden' / 'request access',
// but the underlying error is 0x80095ffe — IP allowlist on Power Platform."
}
If you're picking this up cold and getting auth errors: switch to the
MSFT-AzVPN-ManualVPN profile. Don't believe the msx_auth_status diagnostic about "access".
msx-mcp upstream patch (Node 24 + Windows compatibility)¶
Node 24's CVE-2024-27980 patch blocks execFile('foo.cmd', ...) without shell: true. msx-mcp at v0.17.1 has 3 call sites that hit this on Windows:
src/tools/msx-auth-status/tool.tsline 237src/tools/msx-login/tool.tsline 235src/tools/open-hok-link/tool.tsline 58
Fix: add shell: process.platform === "win32" to each execFile call. The core auth path (lib/auth.ts uses execSync; @azure/identity uses child_process.exec) is unaffected because both default to shell: true.
Upstream PR: https://github.com/mcaps-microsoft/msx-mcp/pull/431 (filed 2026-05-24 from ssutheesh_microsoft/msx-mcp fork). Local clone of msx-mcp stays on the patched fix/node24-execfile-windows branch until upstream merges.
Right-anchored markdown parsing (canonical defensive pattern)¶
msx-mcp tools render markdown tables like:
| # | Name | Value | Consumed | Close Date | Stage | Solution Area | Sales Play | Owner |
| 1 | Foo Opp | $100K | $0 | 2026-06-30 | Inspire & Design | M365 Copilot | ... | ssuth |
| 2 | Renew $1.4M ARR | Cloud Migration | $1.4M | $0 | 2026-Q4 | Empower & Achieve | ... |
The trap: a naive text.split("|") parser treats every | as a column delimiter. Opp names containing | (~1.6% of Sush's 1,080 opps) shift every column to the right by one slot. Result: $0 showing up where Stage should be.
The fix: anchor parsing from the RIGHT side. Last N columns are well-defined data slots; everything between # and Value becomes the name (joined back with | if multi-segment):
const raw = line.split("|").map(s => s.trim());
const trimmed = raw.slice(
raw[0] === "" ? 1 : 0,
raw[raw.length - 1] === "" ? raw.length - 1 : raw.length,
);
// For pipeline: 7 trailing data cols, 1 leading idx col, name = middle (≥1 segment)
const [valueRaw, consumedRaw, closeDateRaw, stageRaw, solutionAreaRaw, salesPlayRaw, ownerRaw] =
trimmed.slice(-7);
const nameParts = trimmed.slice(1, -7);
const name = nameParts.join(" | ");
This applies to ALL parsers in atlas-portfolio (account-team table, pipeline table). The rubber-duck flagged this exact risk during the Phase 1 plan critique. Phase 1 happened to work because NZ account names are pipe-free; Phase 2 hit the bug for real. Default to right-anchored parsing for any future MSX markdown table.
M365 filter heuristic¶
export const M365_NAME_REGEX = /copilot|agent\s*365|m365/i;
export const M365_SOLUTION_AREA = "Cloud and AI Platforms"; // post-FY25 unified MW bucket
- Server-side first: call
get_account_overviewwithsolution_area: 'm365'(aliases to "Cloud and AI Platforms"). msx-mcp's optionset alias resolver handles it. - Name regex as belt-and-braces (v0.2+): for opps where solution_area was misclassified, the name regex catches "Copilot", "Agent 365", "M365" variants. Currently NOT applied — v0.1 trusts the server-side filter and it works for Sush's 1,080-opp book.
- Agent 365 has no clean enum in MSX yet (per the Phase 0 research). Folded into broader "Cloud and AI Platforms" or sometimes left ambiguous. The name regex is the workaround when we add it.
Snapshot schema (v2 — Phase 2)¶
{
"$schema": "atlas-portfolio:customer-list:v2",
"generatedAt": "2026-05-24T23:07:35.134Z",
"generatedBy": "atlas-portfolio v0.1.0 / Phase 2",
"source": {
"tools": ["get_account_team", "get_account_overview"],
"mcpServer": "@mcaps-microsoft/msx-mcp",
"path": "C:\\ssClawy\\msft-internal-mcps\\msx-mcp\\dist\\index.js"
},
"truncated": false,
"rawRowCount": 54,
"customerCount": 54,
"m365Filter": { "solutionArea": "m365 (alias for 'Cloud and AI Platforms')", "status": "open" },
"summary": {
"totalOpps": 1080,
"customersWithOpps": 54,
"customersWithoutOpps": 0,
"totalValue": 498110000,
"errors": 0,
"stageDistribution": [
{ "stage": "Listen & Consult", "count": 484 },
{ "stage": "Inspire & Design", "count": 365 },
{ "stage": "Empower & Achieve", "count": 158 },
{ "stage": "Realize Value", "count": 57 },
{ "stage": "Manage & Optimize", "count": 15 },
{ "stage": "(unknown stage)", "count": 1 }
]
},
"customers": [
{
"accountId": "a6670663-c917-4ac9-ae53-2311b69f9806",
"accountName": "Accident Compensation Corporation",
"tpid": "1563591",
"roles": ["Specialist Sales"],
"opps": [
{
"name": "ACC - M365 Copilot - Q3 FY26",
"value": 480000,
"valueRaw": "$480K",
"consumedRecurring": null,
"closeDate": "2026-06-30",
"stage": "Inspire & Design",
"solutionArea": "Cloud and AI Platforms",
"salesPlay": null,
"owner": "Tam Bagnall"
}
],
"oppsError": null
}
// ... 53 more
]
}
v0.2 will bump to v3 adding per-customer ssp, team[], contacts[], partners[] blocks.
Design DNA — PAC Atlas Console v3 (lifted, not redrawn)¶
Three Phase 0 mockups exist in reference/pac-mockups/ (lifted from PAC's archive). The chosen direction was 05-atlas-console.html ("co-founder build" tag). Real tokens:
:root {
--bg: #0f1115; --panel: #1a1d23; --panel-2: #1f232b;
--border: #2a2f3a; --border-soft: #232831;
--text: #e5e7eb; --text-dim: #9ca3af; --text-muted: #6b7280;
--amber: #fbbf24; --amber-deep: #f59e0b;
--cyan: #06b6d4; --green: #10b981; --orange: #f97316; --red: #ef4444;
--mono: 'JetBrains Mono', 'Cascadia Code', monospace;
--sans: 'Inter', system-ui, sans-serif;
}
Signature character to preserve:
- Section labels in // XX · UPPERCASE typewriter style
- Tilde ~ markers on every heading and KPI tile h4 (PAC TARS voice)
- Square corners (3-6 px radius), NO shadow glow, NO animations
- Big monospace numbers in amber/cyan/neutral with num-lbl uppercase descriptors
- Narrative briefing banner with inline-coloured <strong class="amber|cyan|green"> callouts
- Stage-coloured opp meta lines (Listen=muted, Inspire=cyan, Empower=amber, Realize=green, Manage=orange)
Fonts via Google Fonts CDN (Inter + JetBrains Mono). Acceptable because output is local-only HTML, and graceful fallback chain handles offline.
Why this shipped so fast (4 phases in 5 hours)¶
- Architecture gate first. Per 🚨 #5, surfaced 5 paths before writing code. Path B (static HTML) was deliberately chosen as the opposite of what burned PAC + Atlas CC.
- Rubber-duck before every non-trivial phase. Phase 1's critique adopted 9 of 10 findings — most of which would have surfaced as runtime bugs hours later. Including the right-anchored parser (the rubber-duck called the pipe-in-names risk; we ignored it for Phase 1, paid the price in Phase 2, fixed everywhere).
- Internal-first. Used msx-mcp + ai-sales-kit + Frontier SE patterns instead of reinventing. The auth wall was already solved by the internal MS ecosystem.
- No mock data. Every smoke test was against the real 54-customer book. Bugs surfaced in production-shaped form, not in fixtures that lied.
- Phase queue discipline. Each phase had crisp acceptance criteria. No scope creep across phase boundaries.
- Lifted design from PAC mockups verbatim instead of redesigning. The 05-atlas-console.html mockup was Sush's already-validated direction.
v0.2.0 — TAGGED 2026-05-24 (shipped in one day, ~9 hours total)¶
3-tab app with hash routing + master-detail Opportunities + Atlas LLM commentary baked in at snapshot time.
The 3 tabs (final):
- Overview — briefing line, per-SSP Atlas insight cards (3 × Tam/Riki/Ben rollup), KPI tiles, pipeline-by-stage with per-stage Atlas inline insights below each bar.
- Opportunities — merged opps + per-customer view. Left rail = customers grouped by SSP. Right pane = sortable/filterable opp table; clicking a customer enters "focus mode" with the header showing TPID, SSP badge (with source attribution: TEAM / OPP OWNER / both), pipeline total, next-closing date, anomaly count, coverage gap line (opps owned outside {Tam/Ben/Riki/Sush}), 🕐 stale count, Atlas commentary, and 💬 Ask Atlas clipboard button. Plus "My SSPs / All Opps" sub-toggle.
- Customer 360 — placeholder. Scope intentionally parked while Sush defines what he wants — likely tenant size, Copilot licence counts, active MAU, free Copilot Chat users (research session running 2026-05-24 evening; deliverable: atlas-portfolio/docs/customer-360-playbook.md).
Filters on Opportunities tab: search box · stage chips (with counts) · SSP chips · deal type chips · date filters (this month, this Q, next Q, stale 60+ days close) · anomaly chips (engagement-closed, owner-not-in-known-set, stale 90+ days since modification). Clear-all + CSV export of visible rows. Sticky filter bar stays at top while scrolling 1500+ rows.
Keyboard navigation (v0.2.0): / focus search · j/k row cursor up/down (amber highlight) · o open selected row in MSX · ? toggle keyboard help modal · Esc dismiss modal / blur search. Bottom-right ⌨ FAB opens help too.
Atlas LLM commentary (3 tiers, pre-baked at snapshot time):
1. Per-customer insight (53/54 customers) — 3 sentence read of "what to push, what's at risk, what you might be missing". Hash-cached in data/atlas-insights-cache.json; recomputes only when customer's opp set changes.
2. Per-SSP rollup (3 cards on Overview) — "what does Tam/Riki/Ben's book look like, what to push this week".
3. Per-stage insight (5 cards below pipeline bars) — bottleneck story per stage (Listen & Consult → Manage & Optimize).
All three generated via copilot -p "<prompt>" --effort low subprocess (no shell:true — that tokenises multi-line prompts as 31 args and fails on Windows). Concurrency 4 for per-customer, sequential for SSP + stage. Full regen (cold cache): ~7 min for 54 customers + 8 rollup calls. Warm: ~30s.
Freshness chip on header: FRESH (5m ago) / 1 DAY OLD / STALE (Xd) -- rerun \node scripts/generate.js --insights``.
Phase 7.x lessons (the usefulness pass — where the dashboard became Sush-shaped)¶
Lesson 1 — the M365 filter was hiding the team's actual work. Phase 7.0 discovered that the "ABS team" (AI Business Solutions) Copilot deals all live under msp_solutionarea = "AI Business Solutions", NOT "Cloud and AI Platforms". The original filter caught only the latter. Tam went from 5 owned opps → 23. Total pipeline jumped $498M → $720M. Takeaway: never assume a single solution-area string covers your scope — walk the actual data first.
Lesson 2 — internal MCP tools can silently strip data. Phase 7.1 found that get_account_overview calls a filterClosedEngagements() step internally that removes any opp where msp_engagementstatus = "Closed", even when statecode = Open. 31 of 177 MoH OPEN opps (~17%) were vanishing — including a Riki-owned $670K+ Copilot bundle. Fix: bypass get_account_overview and use dataverse_query direct for full control. The 41 recovered opps are now shown with ⚠ engagement-closed orange badge so the anomaly is visible, not hidden. Takeaway: internal helper tools optimise for the common case; sometimes they optimise away the cases you actually need to see. Always check what they're filtering before trusting them.
Lesson 3 — the latent data-owner bug. The "My SSPs / All Opps" toggle has been silently broken since v0.2.0 first shipped. JS read row.getAttribute('data-owner') to filter, but the attribute was never set in the row template. Toggle just hid everything when scope=my-ssps was active — but the row count was correct (mySspCount computed correctly), so the UI looked right while the table was wrong. Takeaway: when a count and a filter disagree, the filter is lying — and when adding row attributes for sorting/filtering, do a "click-flow > static-load" check (the same lesson the guided quiz growing guardrails enforce).
Lesson 4 — pre-bake the LLM, don't run it on render. Atlas insights cost 5–10s per copilot -p call. Running them on demand (every time a user clicks a customer card) would make the UI feel terrible. Pre-baking at snapshot time means every card shows commentary instantly; the cost is a longer one-time generation step (~7 min cold). Hash-caching means most regens hit cache (~30s warm). Takeaway: for any LLM commentary in a tool, ask "is this content static-per-snapshot or live-per-click?" — if it's static-per-snapshot, pre-bake.
Lesson 5 — [hidden] attribute loses to class-selector display: flex. Mid-session hotfix (2ac8bfc): the new keyboard help modal display: flex on .kbd-help won the cascade over the default browser [hidden] { display: none }, so it showed on page load and blocked everything. Sush caught it within minutes. Fix: .kbd-help[hidden] { display: none !important; }. Takeaway: when adding a positioned/flex modal element, always pair its hidden-by-default markup with an explicit [hidden] selector at equal-or-higher specificity than its display: rule.
Cross-references¶
- PAC architecture (predecessor):
pac-architecture.md·personal-atlas-cockpit-spec.md - Atlas CC architecture (predecessor):
atlas-cc-architecture.md - MSX auth pattern:
msx-auth-fabric-pattern.md - Memory system:
memory-system-architecture.md(where atlas-portfolio fits in the 6-tier annex) - Parallel-safe git:
parallel-git-rules.md(the explicit-paths-only rule atlas-portfolio commits follow)